Compare commits

..

13 commits

Author SHA1 Message Date
c463d94ea5
contributions: track matured CDs properly
All checks were successful
Generic zig build / build (push) Successful in 6m58s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 19s
2026-09-29 07:20:24 -07:00
c020f92449
contributions: honest at-close label 2026-09-29 06:54:12 -07:00
1f7d2c596e
contributions: handle partial sale of lots 2026-09-29 06:50:58 -07:00
8df6bd5c03
contributions: expired or closed options do not count 2026-09-29 06:48:30 -07:00
cc13572d7c
contributions: CD payouts fund purchases do not count 2026-09-29 06:46:11 -07:00
c0dca12623
single definition of closed lot 2026-09-24 15:28:25 -07:00
b3239eefda
make sure large new lots section remains without open_date 2026-09-24 08:54:47 -07:00
60a42dd4ba
allow optional open date/open price for cash and watch 2026-09-24 08:52:36 -07:00
b5f3e8bcc8
keybinding semantic validation 2026-09-24 08:43:39 -07:00
a2c8d478b6
remove hand maintenance of theme data, use comptime introspection 2026-09-24 08:32:02 -07:00
433b83e78f
fix flaky tests 2026-09-24 08:29:06 -07:00
ce65b3c2f8
route projections file parse semantics through lint 2026-09-24 07:14:30 -07:00
40869cb3a4
lint srf files in audit / doctor commands
This also provides ensurance through zig build test that all
fields are documented in markdown so they do not get out of sync
2026-09-24 06:39:23 -07:00
35 changed files with 6192 additions and 379 deletions

View file

@ -133,6 +133,13 @@ already exist and have caught me out:
branches are load-bearing. Render it via `cli.printDirection`
(CLI) or `theme.directionStyle` (TUI) - never map the enum by
hand at a call site.
- `Lot.lotIsOpenAsOf` / `isSoldAsOf` / `hasMaturedAsOf` /
`hasEndedAsOf` / `closePriceAsOf` / `realizedGainLossAsOf` - **the**
answers to "is this lot held / sold / over, and at what price". They
are different questions on purpose: `contributions` needs *sold* (an
expired option recorded with a close is still a sale), holdings need
*held*. There used to be three definitions of "closed" that disagreed;
never test `close_date` or `close_price` directly outside `Lot`.
**Never open-code a per-day up/down comparison.** `close >= open` is
unconditionally true for anything priced once per day at NAV - mutual
@ -177,6 +184,11 @@ grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
# Per-day up/down direction - there must be exactly one rule.
# Any hit outside models/candle.zig is a bug:
grep -rn "close >= .*open\|close > .*open" src/
# Lot liveness - held/sold/ended/matured are defined ONCE on `Lot`.
# Any close_date/close_price read outside models/portfolio.zig is a bug
# (maturity_date reads for sorting, display or identity are fine):
grep -rn "\.close_date\|\.close_price" src/ | grep -v "models/portfolio.zig" | grep -v "test \"\|expect\|close_date = \|close_price = \|close_date:\|close_price:"
```
If the search turns up an existing helper that does what you need,
@ -517,6 +529,16 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional - it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
- **User-authored SRF schemas live with their models.** Each
hand-edited file's model module declares `pub const srf_schema`
(record type, reference-doc path, optional per-discriminator
`field_rules`, optional `semanticCheck`); `src/srf_lint.zig` is the
registry and walker, holding no rules of its own. Valid-name sets are
derived from `std.meta.fields`, `field_rules` is comptime-exhaustive
over the struct, and a doc-sync test ties each model to its reference
page - so none of the three can drift from the fields. See "Adding a
field to a user-authored SRF model".
- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs.
- **Providers are lazily initialized.** `DataService` fields like `td`, `pg`, `fh` start as `null` and are created on first use via `getProvider()`. The provider field name is derived from the type name at comptime.
@ -544,7 +566,7 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
| `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. |
| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `bcov.css` (kcov report styling). |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `gen_config_docs.zig` (extracts backticked identifiers from `docs/reference/config/*.md` for the schema doc-sync test, since `@embedFile` cannot escape `src/`), `bcov.css` (kcov report styling). |
## Code patterns and conventions
@ -753,6 +775,69 @@ snapshot`, and that is the correct single-file load. The
distinction: snapshot files are immutable historical records,
not the user's currently-edited portfolio.
### Adding a field to a user-authored SRF model
Every hand-edited SRF file has a schema that its own model module
declares as `pub const srf_schema`, registered in `src/srf_lint.zig`'s
`schemas`. The build enforces the contract, so you will be told what
to do rather than having to remember:
1. **Add the field to the model struct.** Nothing else, then
`zig build test`.
2. If the model declares `field_rules` (only `Lot` does today), the
build **fails** with the field name and three copy-pasteable
options: read for every discriminator value, read only for some
(`.only = &.{...}`), or `.derived = true`. Classify it.
3. The doc-sync test then **fails** until the field appears in the
model's reference page (`doc_path`), or is added to `undocumented`
with a comment saying why it is not user-facing.
Both failures are the point. Rules and docs that live away from the
fields they describe drift from them - `portfolio-srf.md` documented
14 of `Lot`'s 22 fields before this existed, and adding a field had no
consequence.
Why this matters: SRF's `fields.to(T)` **silently discards** any record
key that names no field of `T` (`field_match` in srf.zig is write-only
- see the module doc on `src/srf_lint.zig`). 19 of `Lot`'s 22 fields
have defaults, so a typo'd key is indistinguishable from an omitted
one. `zfin doctor` reports these per-file with a reference-page
pointer; `zfin audit`'s Section 8 is the routine-use summary, silent
when clean.
The suggestion matchers are deliberately conservative - case-insensitive
equality and either-direction prefix, no edit distance - so they cannot
produce a wrong guess. They therefore **miss mid-word substitutions**
(`update_cadance` -> `update_cadence` gets no suggestion). The backstop
is that `audit` prints the file's full valid-name set, derived from
`std.meta.fields`, whenever a finding is about a field name
(`Result.hasNameFindings`), and `doctor` points at the reference page,
which the doc-sync test guarantees is complete. Keep both if you touch
the renderers; they are what make the conservatism affordable.
`Rule.only` is a claim that the value is **silently ignored** for every
other discriminator value, so each one must cite the gate that proves
it. When a field's reach is ambiguous, leave it permissive - a missed
nudge costs nothing, a false "zfin ignores this" sends someone chasing
a bug that does not exist.
Rules that aren't about a single field's name go in the schema's
`semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void`
(the signature is comptime-checked). `ctx.today` is the real current
day - the date rules (`Lot`'s future `close_date` / `open_date` /
`price_date`) are nonsensical against a back-dated reference. The same
permissive rule applies: `Lot`'s "close_date without close_price" fires
only for stock lots because realized P&L is only computed for them, and
each such scope limit cites its gate in a comment.
Rules that span records (limits, "at most one of") go in the schema's
`fileCheck(data: []const u8, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void`
instead, which gets the whole file and must set `sink.line` per record.
When a parser already enforces such rules, have `fileCheck` run the
parser with the sink rather than re-implement them - `projections.srf`
does this: `parseWith` reports through a `Reporter` that logs normally
and records findings under the lint, so each rule has one definition.
### Adding a new provider
1. Create `src/providers/newprovider.zig` following the existing struct pattern

View file

@ -73,6 +73,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "websocket", .module = websocket_dep.module("websocket") },
.{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod },
.{ .name = "config_docs", .module = configDocsModule(b) },
};
// Generate Shiller annual returns data from ie_data.csv.
@ -164,6 +165,57 @@ pub fn build(b: *std.Build) void {
}
}
/// Produce the `config_docs` module: the set of backticked identifiers
/// in each `docs/reference/config/*.md` reference page, extracted by
/// `build/gen_config_docs.zig`.
///
/// Consumed by `src/srf_lint.zig`'s doc-sync test, which asserts every
/// field of every user-authored model appears in that model's reference
/// page. `@embedFile` cannot reach outside `src/`, hence the generator.
///
/// The directory is ENUMERATED rather than listed, so adding a new
/// reference page needs no edit here. Each page is passed as a file arg
/// so the build re-runs when one changes.
fn configDocsModule(b: *std.Build) *std.Build.Module {
const rel = "docs/reference/config";
const gen = b.addExecutable(.{
.name = "gen_config_docs",
.root_module = b.createModule(.{
.root_source_file = b.path("build/gen_config_docs.zig"),
.target = b.graph.host,
}),
});
const run = b.addRunArtifact(gen);
const output = run.addOutputFileArg("config_docs.zig");
var names: std.ArrayList([]const u8) = .empty;
if (b.build_root.handle.openDir(b.graph.io, rel, .{ .iterate = true })) |dir| {
var d = dir;
defer d.close(b.graph.io);
var it = d.iterate();
while (it.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, ".md")) continue;
names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
}
} else |_| {}
// Sorted so the generated file - and therefore the build cache
// hash - does not depend on directory-iteration order.
std.mem.sort([]const u8, names.items, {}, struct {
fn lessThan(_: void, a: []const u8, c: []const u8) bool {
return std.mem.lessThan(u8, a, c);
}
}.lessThan);
for (names.items) |n| {
run.addFileArg(b.path(b.fmt("{s}/{s}", .{ rel, n })));
}
return b.createModule(.{ .root_source_file = output });
}
/// Produce the `build_info` module exposing `version` (derived from `git
/// describe`) and `build_timestamp` (committer timestamp of HEAD).
/// Consumed as `@import("build_info")` from `src/version.zig`.

139
build/gen_config_docs.zig Normal file
View file

@ -0,0 +1,139 @@
/// Build-time generator: extracts the set of backticked identifiers
/// from each `docs/reference/config/*.md` reference page and emits them
/// as a Zig source file.
///
/// Exists because `@embedFile` cannot escape the package path, so
/// `src/` code has no way to read `docs/`. The alternative - reading
/// the markdown at test time via cwd - would make the doc-sync check
/// dependent on where the test binary was launched from, and a check
/// that can silently skip is a check that rots.
///
/// Only the NAME SET is extracted, never the prose. That keeps the
/// output free of string-escaping concerns and small (a few hundred
/// identifiers), and it is exactly what the consumer needs:
/// `srf_lint`'s doc-sync test asserts every field of each model appears
/// in that model's reference page.
///
/// An "identifier" here is a backtick-delimited token matching
/// `[a-z][a-z0-9_]*` - the shape of every SRF field name. Prose words
/// in backticks (`true`, `null`, `stock`) come along harmlessly; the
/// test only ever asks whether a known field name is PRESENT, never
/// whether an extracted name is a real field.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const allocator = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(allocator);
if (args.len < 3) {
var stderr_buf: [160]u8 = undefined;
var stderr = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr.interface.writeAll("Usage: gen_config_docs <output.zig> <doc.md>...\n");
try stderr.interface.flush();
std.process.exit(1);
}
const out_file = try std.Io.Dir.cwd().createFile(io, args[1], .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var file_writer = out_file.writer(io, &out_buf);
const w = &file_writer.interface;
try w.writeAll(
\\// Auto-generated from docs/reference/config/*.md - do not edit.
\\// Regenerate: zig build (runs build/gen_config_docs.zig)
\\
\\/// One reference page's backticked identifiers.
\\pub const Doc = struct {
\\ /// Basename, e.g. "portfolio-srf.md".
\\ file: []const u8,
\\ names: []const []const u8,
\\};
\\
\\pub const docs = [_]Doc{
\\
);
for (args[2..]) |path| {
const body = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024));
const base = std.fs.path.basename(path);
var names: std.ArrayList([]const u8) = .empty;
try collectIdents(allocator, body, &names);
try w.print(" .{{ .file = \"{s}\", .names = &.{{", .{base});
for (names.items, 0..) |n, i| {
if (i % 6 == 0) try w.writeAll("\n ");
try w.print("\"{s}\", ", .{n});
}
try w.writeAll("\n } },\n");
}
try w.writeAll(
\\};
\\
\\/// The `Doc` for `file`, or null when that page was not built in.
\\pub fn find(file: []const u8) ?Doc {
\\ for (docs) |d| {
\\ if (std.mem.eql(u8, d.file, file)) return d;
\\ }
\\ return null;
\\}
\\
\\const std = @import("std");
\\
);
try w.flush();
}
/// Append every deduplicated backticked `[a-z][a-z0-9_]*` token in
/// `body` to `out`. Slices borrow from `body`.
fn collectIdents(
allocator: std.mem.Allocator,
body: []const u8,
out: *std.ArrayList([]const u8),
) !void {
var i: usize = 0;
while (i < body.len) {
if (body[i] != '`') {
i += 1;
continue;
}
// Skip fenced code blocks wholesale - a ```zig sample can
// mention a field in a context the reference table does not,
// and counting it would let a field be "documented" by an
// example alone.
if (std.mem.startsWith(u8, body[i..], "```")) {
const rest = body[i + 3 ..];
const close = std.mem.indexOf(u8, rest, "```") orelse break;
i += 3 + close + 3;
continue;
}
const rest = body[i + 1 ..];
const close = std.mem.indexOfScalar(u8, rest, '`') orelse break;
const raw = rest[0..close];
i += 1 + close + 1;
// The docs name a field both bare (`symbol`, in reference
// tables) and on-wire (`symbol::`, `shares:num:100`, in prose
// and examples). Cut at the first separator so both spellings
// count as documenting the field.
const tok = raw[0 .. std.mem.indexOfScalar(u8, raw, ':') orelse raw.len];
if (!isIdent(tok)) continue;
for (out.items) |seen| {
if (std.mem.eql(u8, seen, tok)) break;
} else {
try out.append(allocator, tok);
}
}
}
fn isIdent(tok: []const u8) bool {
if (tok.len == 0) return false;
if (tok[0] < 'a' or tok[0] > 'z') return false;
for (tok) |c| {
const ok = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or c == '_';
if (!ok) return false;
}
return true;
}

View file

@ -102,6 +102,8 @@ With no flags, `zfin audit` first prints a health report:
- **Accounts overdue for update** -- accounts past their
`update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)).
- **Brokerage files** it discovered, which it then reconciles.
- **Config file problems** -- field-name typos in your `.srf` files, impossible
lot dates, and values zfin had to ignore or adjust.
```
Portfolio hygiene
@ -117,6 +119,128 @@ With no flags, `zfin audit` first prints a health report:
"No update history found" is a nudge, not an error -- silence accounts
you don't actively track with `update_cadence::none`.
### Config file problems
Your `.srf` files can be wrong in ways that don't stop them loading.
zfin reads what it understands and quietly ignores or works around the
rest, so a mistake can change your numbers with no error at all.
`zfin audit` checks `portfolio*.srf`, `accounts.srf`, `metadata.srf`,
`transaction_log.srf`, `projections.srf`, and `watchlist.srf` for three
kinds of problem, and prints nothing when there aren't any:
```
Config file problems
portfolio.srf
line 3: unrecognized field 'price_dat' - did you mean 'price_date'?
line 4: BND close_date 2062-03-14 is in the future - the lot is still counted as held until then
line 5: AAPL has close_date but no close_price - the sale's realized gain is recorded as 0
valid fields:
symbol shares open_date open_price close_date close_price note
label account security_type maturity_date rate drip ticker price
price_date price_ratio split_factor underlying strike multiplier
option_type
accounts.srf
line 2: account 'Sample Brokerage': audit_large_lot_threshold must be > 0 (got 0); ignored, so the audit uses its default
line 4: account 'Sample Roth': tax_mix_* must not name the account's own tax_type; the tax mix is ignored and the account counts wholly as roth
```
Each finding names the line of the record it came from, and the date
and value findings also say what zfin is doing instead of what you
wrote.
#### 1. Field names (every file)
SRF matches record keys against field names exactly. A key that matches
nothing is **discarded without complaint** -- a misspelled `price_dat::`
is indistinguishable from having left the price date out. Most fields
have defaults, so most typos change behavior with no output at all.
| Report | Meaning |
|-----------------------------|----------------------------------------------------------------------------------------|
| `did you mean '...'?` | A dropped or doubled character. The suggested name is a real field. |
| `differs only by case` | `Account` is not `account`. Matching is case-sensitive, so this silently does nothing. |
| `ignored for security_type` | A real field that this kind of lot never reads -- e.g. `rate` outside a `cd`. |
| `appears twice` | SRF keeps the **first** occurrence. Editing the second one has no effect. |
| `derived by zfin` | zfin computes this field; a hand-written value is overwritten. Delete it. |
| `unrecognized field` | No match and no close relative. Check it against the `valid fields` list. |
The suggestions are deliberately cautious -- they only fire when a real
field name is a prefix of what you typed, or differs from it only by
case -- so a suggestion is never a guess. That means a typo in the
*middle* of a name (`update_cadance`) gets no suggestion, which is why
the **`valid fields` list** is printed whenever a field name is in
question. It comes straight from the code, so unlike any document it
cannot be out of date.
#### 2. Lot dates (`portfolio*.srf`)
These fields parse fine but describe a lot that can't exist. Each one
changes what zfin counts -- a future `close_date` keeps the lot held
until that day -- and each is almost certainly not what you meant.
| Report | What zfin does with it |
|--------------------------------------|--------------------------------------------------------------------------------------------|
| `close_date ... is in the future` | Counts the lot as held until that date. Usually a mistyped year. |
| `close_date ... is before open_date` | The lot is never counted as held on any date. |
| `has close_date but no close_price` | Stock lots only. The sale's realized gain is recorded as 0. |
| `has close_price but no close_date` | The lot stays held, and the close price is ignored. |
| `open_date ... is in the future` | The lot is left out of your positions until that date. |
| `price_date ... is in the future` | Stock lots only. The manual price counts as fresh, so it is never flagged as stale. |
A close dated today is already closed, and a same-day buy-and-sell
(`close_date` equal to `open_date`) is fine. A future `maturity_date` is
normal -- that is the field for a CD or option that ends on a known
date. Watch lots are exempt, since zfin reads only their `symbol`. See
[Closed lots](../reference/config/portfolio-srf.md#closed-lots).
#### 3. Values zfin rejects or adjusts (`accounts.srf`, `projections.srf`)
Values that parse but can't be used as written. zfin replaces or clamps
them so the file still loads -- just not the way you wrote it. Each of
these is also logged to stderr when the file loads; the audit is where
it gets a line number and can't scroll past.
**`accounts.srf`**
| Report | What zfin does instead |
|-----------------------------------------------|-------------------------------------------------------------------------|
| `audit_large_lot_threshold must be > 0` | Uses the audit's built-in threshold. |
| `harvested must be a finite number` | Drops the harvested figure (only reachable by typing `inf` or `nan`). |
| `tax_mix_* ...` (any of the four rules below) | Ignores the whole tax mix; the account counts wholly as its `tax_type`. |
The four tax-mix rules: every `tax_mix_*` carve-out must be above 0 and
a finite number, none may name the account's own `tax_type`, and
together they must sum to less than 100. See
[`accounts.srf`](../reference/config/accounts-srf.md).
**`projections.srf`**
| Report | What zfin does instead |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| `return_cap`, `annual_contribution`, `target_spending`, or `survivor_spending_pct` `must be >= 0` | Ignores that value; an earlier line's value, or the default, stands. |
| `horizon`, `horizon_age`, or `max_accumulation_years` `must be > 0` | Ignores that value. |
| `spending_change capped` | Clamps it to 10%/yr either way -- a larger figure is almost always a units slip. |
| `max_accumulation_years capped` | Clamps it to 100. |
| `benchmark_stock` / `benchmark_bond` `must be 1..16 chars` | Ignores the symbol and keeps `SPY` / `AGG`. |
| `retirement_target must be 90, 95, or 99` | Ignores the annotation on that horizon. |
| `retirement_target set on multiple horizons` | Ignores **all** of them and uses the default promotion rule. Names both lines. |
| `horizon limit` / `horizon_age limit` (more than 8 of either) | Ignores the extras. |
| `event limit` (more than 16), or an event with `start_age 0` | Ignores that event. |
| `birthdate person index ... exceeds limit` (more than 4 people) | Ignores that birthdate. |
| `skipping malformed record` | Drops the whole line -- e.g. a misspelled `type::`, or text where a number belongs. |
| `stopped reading` | Ignores that line **and everything after it**. |
The last two matter most: this file never fails to load, so a line zfin
couldn't read otherwise just quietly reverts to defaults. See
[`projections.srf`](../reference/config/projections-srf.md).
#### Also in `zfin doctor`
`zfin doctor` reports the same findings one file at a time, with a link
to each file's reference page, and also covers `keys.srf`, `theme.srf`,
and `acknowledgments.srf`. Neither command changes its exit code over
any of these -- they're nudges, not failures.
## Auto-discovery (and your download folder)
With no `--fidelity`/`--schwab` flag, zfin looks for exports in two

View file

@ -54,13 +54,29 @@ rather than counting toward the total:
alongside the account's cash going down.
- **Reallocating -- selling one holding to buy another in the same
account.** The sale's proceeds offset the repurchase.
- **A CD paying out.** A CD's payout counts like a sale: rolling it
into a new CD, or buying with the payout, isn't new money.
A sale is valued at `close_price` when you record one (see
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
actually realized. If you delete the lot outright instead, there is no
`close_price` to read and the current market price stands in -- accurate
for a recent sale, less so for one made long before the end of the
window. The report labels which was used: `at close` or `at mark`.
actually realized. If you delete the lot outright instead, or record the
close without a `close_price`, the current market price stands in --
accurate for a recent sale, less so for one made long before the end of
the window. The report labels which was used: `at close` only when every
lot on the line had a `close_price`, otherwise `at mark`.
An option removed after it expired is worth $0 -- whether it expired
worthless or was exercised, the premium isn't proceeds. (An exercise
shows up separately, as the stock and cash that moved.)
A partial sale is recorded by splitting the lot: keep the shares you
still hold on one line, and put the sold shares on another with
`close_date` and `close_price`. Only the sold shares count, at their
`close_price`, whichever order the lines are in and whether the sold one
stays in `portfolio.srf` or moves to `portfolio_closed.srf`. Deleting a
lot you had already closed changes nothing, and neither does adding old
closed lots to the file -- though an added one appears in the report as
a purchase funded by its own sale.
Closing a position that has accumulated a lot per dividend
reinvestment retires many lots at once, so sales collapse to one line
@ -71,6 +87,17 @@ funded anything, and are treated accordingly. On an account marked
`cash_is_contribution::true` they also cancel that account's cash
credit, since the sale is not new money even though cash arrived.
A CD that matures pays out in the window containing its maturity date
-- between the dates of the two commits being compared, or today for
uncommitted changes -- whether you close it,
delete it, archive it to `portfolio_closed.srf`, or leave it where it
is. Closing or removing it in a later window changes nothing. A CD
redeemed before maturity pays out in the window you close or remove it.
On an account marked `cash_is_contribution::true`, update its cash
balance in that same window: a payout that reaches the cash line a
commit later reads as new money there.
Movement **between** accounts is a different matter -- zfin cannot tell
it from a contribution, so declare it in
[`transaction_log.srf`](../config/transaction-log-srf.md). An explicit

View file

@ -18,6 +18,12 @@ One binding per line:
action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
```
| Field | Type | Required | Description |
|----------|--------|----------|---------------------------------------------------------------------------------------------------------|
| `action` | string | Yes | The action to bind. Global actions are listed below; a tab-local `scope` changes which names are valid. |
| `key` | string | Yes | The key combination, e.g. `q`, `ctrl+c`, `F5`, `page_down`. |
| `scope` | string | No | Omitted or `global` for a global binding; a tab name (e.g. `options`) for a tab-local one. |
- **Modifiers:** `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+c`).
- **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`,
`left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`,
@ -31,6 +37,12 @@ action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
A tab-local binding may not reuse a globally-bound key; zfin refuses
to start if you create that conflict.
A binding whose `scope` names no tab, or whose `action` isn't one of
that tab's actions, never fires. It also doesn't fall back to the
tab's default binding, because any override for a tab replaces all of
its defaults. zfin prints a warning for each one when the TUI starts,
and `zfin doctor` reports them with line numbers.
## Default global bindings
```srf

View file

@ -34,14 +34,15 @@ symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::
| Field | Type | Required | Description |
|-----------------|--------|----------|---------------------------------------------------------------------------------------|
| `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. |
| `shares` | number | Yes | Share count (or face value for cash/CDs). Negative for short option positions. |
| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Not required for `cash`/`watch`. |
| `open_price` | number | Yes\*\* | Purchase price per share. \*\*Not required for `cash`/`watch`. |
| `shares` | number | Yes\*\*\* | Share count. For `cash`, `cd` and `illiquid`, the dollar value (face value for CDs). Negative for short option positions. \*\*\*Optional for `watch`. |
| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Optional for `cash`/`watch`; see [Minimal cash and watch lots](#minimal-cash-and-watch-lots). |
| `open_price` | number | Yes\*\* | Purchase price per share; `1` for `cash`, `cd` and `illiquid`, whose `shares` are dollars. \*\*Optional for `cash`/`watch`. |
| `close_date` | string | No | Sale date. Omit for an open lot. See [Closed lots](#closed-lots). |
| `close_price` | number | No | Sale price per share. See [Closed lots](#closed-lots). |
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. |
| `account` | string | No | Account name. Should match an `account::` entry in [`accounts.srf`](accounts-srf.md). |
| `note` | string | No | Free-text note (shown in cash/CD/illiquid tables). |
| `label` | string | No | Display name for the symbol column, overriding `symbol`/`ticker` |
| `ticker` | string | No | Ticker alias used for price fetching when `symbol` is a CUSIP. |
| `price` | number | No | Manual price override (for securities the providers don't cover). |
| `price_date` | string | No | Date of the manual price (`YYYY-MM-DD`), for staleness display. |
@ -71,8 +72,8 @@ Each line below is valid on its own. These mirror the bundled
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
# Cash (no symbol, no open_date/open_price needed)
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Cash (no symbol, open_date or open_price needed)
security_type::cash,shares:num:48000.00,account::Joint taxable
# Closed (sold) lot
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50
@ -89,13 +90,33 @@ symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,pri
# Option: a written (short) call. Negative shares = contracts sold.
security_type::option,symbol::AAPL 06/20/2025 200.00 C,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,option_type::call,underlying::AAPL,strike:num:200,maturity_date::2025-06-20,account::Brokerage
# CD
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
# CD: shares is the face value, open_price is 1
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:1,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
# Illiquid asset (net worth only)
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence
# Illiquid asset (net worth only): shares is the current value, open_price is 1
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:1,note::Primary residence
```
## Minimal cash and watch lots
A `cash` lot may leave out `open_date` and `open_price`, and a `watch`
lot may also leave out `shares`:
```srf
security_type::cash,shares:num:48000.00,account::Brokerage
security_type::watch,symbol::NVDA
```
zfin fills in what's missing the same way `zfin import` writes it:
`open_date` 1970-01-01 and `open_price` 1 for cash, and zeros for a
watch lot. A cash lot with no `open_date` therefore counts as held on
every date, including back-dated `--as-of` views; give it one if that
matters.
Every other type needs all three. A stock, option, CD or illiquid lot
missing one is skipped, and `zfin doctor` names the field, e.g.
`open_price is required for stock lots`.
## Closed lots
A lot with `close_date` set is sold. Both fields are optional -- an open
@ -110,6 +131,25 @@ two things beyond bookkeeping:
`close_price` the current market price stands in, which is close
enough for a recent sale and wrong for an old one.
A `close_date` must not be in the future. A close dated today is already
closed, but a later date leaves the lot counted as held -- everywhere,
including `zfin contributions` -- until that day. For a scheduled end (a
CD or option), use `maturity_date`.
A `close_price` only counts once its `close_date` has arrived. Until
then the lot is held and priced live, like any other held lot.
`zfin audit` and `zfin doctor` flag these lot-lifecycle mistakes:
- `close_date` in the future, or before `open_date`.
- `close_date` without `close_price` on a stock lot (the realized gain
is recorded as 0), or `close_price` without `close_date` on any lot.
- `open_date` in the future (the lot is not counted until then).
- `price_date` in the future on a stock lot (the manual price is never
flagged as stale).
Watch lots are exempt; zfin reads only their `symbol`.
You can either edit the lot where it sits or move it to a sibling file
such as `portfolio_closed.srf` -- the `portfolio*.srf` glob picks it up
either way, so realized gains and back-dated (`--as-of`) views stay

View file

@ -46,6 +46,7 @@ negative::#e06c75
| `info` | Informational highlights |
| `select_bg` / `select_fg` | Selected row |
| `border` | Borders and rules |
| `bar_fill` | Bar-chart fill |
Run `zfin interactive --default-theme` for the full key list with the
default values filled in.

View file

@ -47,6 +47,7 @@ transfer::2026-05-02,type::cash,amount:num:4700,from::Sample IRA,to::Sample Brok
| `from` | string | Yes | Source account name (matches an `account::` in your portfolio). |
| `to` | string | Yes | Destination account name. |
| `dest_lot` | string | Yes | Where it landed: `cash`, or `SYMBOL@YYYY-MM-DD` for a specific lot. |
| `note` | string | No | Free-text reminder of why the transfer happened. |
## `type::cash` vs `type::in_kind`

View file

@ -12,6 +12,7 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const srf_lint = @import("../srf_lint.zig");
const log = std.log.scoped(.accounts);
@ -368,6 +369,94 @@ pub const AccountTaxEntry = struct {
}
};
/// Every semantic problem `accounts.srf` validation can find in one
/// entry. All three fall back to a safe default rather than failing the
/// parse, which is exactly why they need reporting: the account still
/// loads, just not the way the user wrote it.
///
/// Single source of truth for these rules. `parseAccountsFile` logs
/// them to stderr as it parses; `srf_schema.semanticCheck` surfaces the
/// same set through `zfin doctor` / `zfin audit`, where they are
/// attached to a line number and cannot scroll past. Both call
/// `checkEntry` - the conditions are written once.
pub const EntryProblems = struct {
/// `audit_large_lot_threshold` was zero or negative. Zero would
/// flag every new lot; negative is meaningless.
bad_lot_threshold: ?f64 = null,
/// `harvested` was `inf`/`nan` - only reachable by hand-typing it.
non_finite_harvested: ?f64 = null,
/// A declared `tax_mix_*` set that breaks the carve-out rules, so
/// the account reverts to its bare `tax_type`.
tax_mix: ?TaxMixProblem = null,
pub fn any(self: EntryProblems) bool {
return self.bad_lot_threshold != null or
self.non_finite_harvested != null or
self.tax_mix != null;
}
};
/// Validate one parsed `accounts.srf` entry. Pure - no logging, no
/// allocation - so both the parser and the linter can call it.
pub fn checkEntry(entry: AccountTaxEntry) EntryProblems {
var p: EntryProblems = .{};
if (entry.audit_large_lot_threshold) |t| {
if (t <= 0) p.bad_lot_threshold = t;
}
if (entry.harvested) |h| {
if (!std.math.isFinite(h)) p.non_finite_harvested = h;
}
p.tax_mix = entry.taxMixChecked().problem;
return p;
}
/// Schema contract for `accounts.srf`. See `srf_lint.validateSchema`.
///
/// No `field_rules`: nothing here is conditional on another field's
/// value. The `tax_mix_*` family IS interdependent, but that is a
/// field-COMBINATION rule rather than a per-field applicability one, so
/// it goes through `semanticCheck` instead.
pub const srf_schema = struct {
pub const Record = AccountTaxEntry;
pub const file_label: []const u8 = "accounts.srf";
pub const doc_path: []const u8 = "reference/config/accounts-srf.md";
/// Surface `checkEntry`'s findings through the lint channel.
///
/// These were already detected at parse time and logged with
/// `std.log.warn`, which still happens - but a stderr line emitted
/// mid-render, with no file or line number, is how they came to be
/// ignored. Reporting them here attaches each to the record that
/// caused it and puts it in a report the user is reading on purpose.
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
// No date rules here: `doctor`'s Section B already reports a
// future `harvested_date` / `tax_mix_date`, grouped by account.
_ = ctx;
const p = checkEntry(rec);
if (p.bad_lot_threshold) |t| {
try sink.addOwned(.semantic, "audit_large_lot_threshold", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignored, so the audit uses its default",
.{ rec.account, t },
));
}
if (p.non_finite_harvested) |h| {
try sink.addOwned(.semantic, "harvested", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': harvested must be a finite number (got {d}); ignored",
.{ rec.account, h },
));
}
if (p.tax_mix) |problem| {
try sink.addOwned(.semantic, "tax_mix", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': {s}; the tax mix is ignored and the account counts wholly as {s}",
.{ rec.account, problem.label(), @tagName(rec.tax_type) },
));
}
}
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum {
@ -559,39 +648,43 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
continue;
};
// Validate once, report twice: these same problems are surfaced
// with line numbers by `srf_schema.semanticCheck` (see
// `src/srf_lint.zig`). The conditions live in `checkEntry` so
// the two channels cannot disagree about what is wrong.
const problems = checkEntry(entry);
// A zero/negative large-lot threshold is nonsensical (zero
// flags every new lot; negative is meaningless). Reject it and
// treat the account as unset so the audit uses its default.
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
if (t > 0) break :blk t;
const lot_threshold: ?f64 = if (problems.bad_lot_threshold) |t| blk: {
// No-op under `zig build test`: the parser's own tests feed
// invalid thresholds (0, negative) on purpose to verify they
// are rejected, and the warn spam pollutes test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
break :blk null;
} else null;
} else entry.audit_large_lot_threshold;
// `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);
const harvested: ?f64 = if (problems.non_finite_harvested) |h| blk: {
// 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;
} else if (entry.harvested) |h| @abs(h) else null;
// A declared tax mix that breaks the rules falls back to the
// bare `tax_type`. Warn so a typo isn't invisible; the raw
// fields are deliberately left in place so `zfin doctor` can
// report the same problem against the file the user edits.
if (entry.taxMixChecked().problem) |p| {
if (problems.tax_mix) |p| {
// Silent under `zig build test`: the parser's own tests feed
// invalid mixes on purpose to verify the fallback, and the
// warn spam pollutes test output.
@ -3318,3 +3411,91 @@ test "abbreviateSector: known long labels collapse, others pass through" {
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
try std.testing.expectEqualStrings("", abbreviateSector(""));
}
// ── checkEntry / srf_schema.semanticCheck ──
/// Lint `data` as accounts.srf and return only the semantic findings
/// (the raw field-name pass is `srf_lint`'s own territory).
fn lintAccounts(data: []const u8) !srf_lint.Result {
var r = try srf_lint.check(std.testing.allocator, data, srf_lint.shapeOfSchema(srf_schema));
errdefer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = Date.fromYmd(2026, 1, 1) });
return r;
}
test "checkEntry: a clean entry has no problems" {
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional };
try std.testing.expect(!checkEntry(e).any());
}
test "checkEntry: zero and negative large-lot thresholds are rejected, positive kept" {
const zero = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 0 };
try std.testing.expectEqual(@as(?f64, 0), checkEntry(zero).bad_lot_threshold);
const neg = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = -5 };
try std.testing.expectEqual(@as(?f64, -5), checkEntry(neg).bad_lot_threshold);
const ok = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 25_000 };
try std.testing.expectEqual(@as(?f64, null), checkEntry(ok).bad_lot_threshold);
}
test "checkEntry: non-finite harvested is rejected, a negative magnitude is not" {
const inf = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.inf(f64) };
try std.testing.expect(checkEntry(inf).non_finite_harvested != null);
const nan = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.nan(f64) };
try std.testing.expect(checkEntry(nan).non_finite_harvested != null);
// A negative figure is legal - the parser normalizes with @abs.
const neg = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = -1234.0 };
try std.testing.expectEqual(@as(?f64, null), checkEntry(neg).non_finite_harvested);
}
test "checkEntry: a tax mix naming the account's own tax_type is a problem" {
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional, .tax_mix_traditional = 30 };
try std.testing.expectEqual(TaxMixProblem.redundant_primary, checkEntry(e).tax_mix.?);
}
test "srf_schema.semanticCheck: reports the bad large-lot threshold with the account name" {
var r = try lintAccounts("#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:0\n");
defer r.deinit();
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
try std.testing.expectEqual(srf_lint.Kind.semantic, r.findings[0].kind);
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Brokerage") != null);
try std.testing.expect(std.mem.indexOf(u8, msg, "must be > 0") != null);
}
test "srf_schema.semanticCheck: reports non-finite harvested" {
var r = try lintAccounts("#!srfv1\naccount::Sample Trust,tax_type::taxable,harvested::inf\n");
defer r.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Trust") != null);
try std.testing.expect(std.mem.indexOf(u8, msg, "finite") != null);
}
test "srf_schema.semanticCheck: reports a broken tax mix and names the fallback" {
var r = try lintAccounts("#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_traditional:num:30\n");
defer r.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample IRA") != null);
// The fallback behavior is the actionable part, not just "invalid".
try std.testing.expect(std.mem.indexOf(u8, msg, "counts wholly as traditional") != null);
}
test "srf_schema.semanticCheck: a clean accounts.srf reports nothing" {
var r = try lintAccounts(
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable,harvested:num:1200,harvested_date::2025-01-01
\\account::Sample IRA,tax_type::traditional,tax_mix_roth:num:20
\\
);
defer r.deinit();
try std.testing.expect(r.isClean());
}

View file

@ -15,19 +15,45 @@ const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig");
const srf_lint = @import("../srf_lint.zig");
const Date = @import("../Date.zig");
/// `log.warn` wrapper that no-ops under `zig build test`. Used for
/// validation warnings emitted while parsing user-supplied
/// `projections.srf` records: the test suite intentionally feeds
/// invalid inputs to `validRetirementTarget` and the config-loader
/// to verify they're rejected, but the resulting stderr noise
/// pollutes test output. Keep using `log.warn` directly when the
/// warning is interesting in tests too.
fn warnUser(comptime fmt: []const u8, args: anytype) void {
if (builtin.is_test) return;
log.warn(fmt, args);
}
/// Where `projections.srf` validation problems go.
///
/// The parser is the ONLY definition of these rules. Several depend on
/// state across the whole file - the horizon / horizon_age / event /
/// person limits, and "at most one `retirement_target`" - so a separate
/// lint copy would have to re-implement the parse loop, and could then
/// drift from it. Instead the one loop reports through this, either way:
///
/// - no sink (every normal load): `log.warn`, as it always has. Silent
/// under `zig build test`, whose fixtures feed invalid values on
/// purpose.
/// - a sink (`srf_schema.fileCheck`, run by `zfin doctor` and
/// `zfin audit`): a lint finding carrying the record's line number,
/// and nothing on stderr.
///
/// `args: anytype` is a `std.fmt` argument tuple, forwarded unchanged to
/// `log.warn` / `std.fmt.allocPrint` - the same shape as `std.log`
/// itself, and as the `warnUser` helper this replaced.
const Reporter = struct {
sink: ?*srf_lint.Sink = null,
/// Stamp subsequent findings with `line`. No-op without a sink.
fn at(self: Reporter, line: u32) void {
if (self.sink) |s| s.line = line;
}
/// `key` is the field the problem is about. It is used for rollup
/// identity only - the message itself names it too.
fn warn(self: Reporter, key: []const u8, comptime fmt: []const u8, args: anytype) error{OutOfMemory}!void {
if (self.sink) |s| {
try s.addOwned(.semantic, key, try std.fmt.allocPrint(s.allocator, fmt, args));
} else if (!builtin.is_test) {
log.warn("projections: " ++ fmt, args);
}
}
};
// ── Life events ─────────────────────────────────────────────────
@ -722,6 +748,31 @@ const SrfProjection = union(enum) {
event: SrfEvent,
};
/// Schema contract for `projections.srf`. See
/// `srf_lint.validateSchema`.
///
/// The highest-value file in the registry to lint: all 17 `SrfConfig`
/// fields have defaults, so SRF silently discards a typo'd one, and
/// `parseProjectionsConfig` has an infallible signature that degrades
/// every failure to defaults. A misspelled `target_spending` therefore
/// changes projected retirement math with no output whatsoever.
pub const srf_schema = struct {
pub const Record = SrfProjection;
pub const file_label: []const u8 = "projections.srf";
pub const doc_path: []const u8 = "reference/config/projections-srf.md";
/// Run the real parser with a sink attached, so every rule it
/// enforces is reported with a line number instead of logged. See
/// `Reporter` for why this is a `fileCheck` (whole file) rather than
/// a per-record `semanticCheck`: half the rules are limits and
/// "at most one" constraints that span records.
pub fn fileCheck(data: []const u8, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
// No date rules in this file, so `today` is unused.
_ = ctx;
_ = try parseWith(data, .{ .sink = sink });
}
};
/// Clamp on the magnitude of `spending_change` (10%/yr real, in
/// either direction). A larger drift is almost certainly a units
/// typo - someone entering a fraction (0.02) where a whole percent
@ -782,6 +833,18 @@ pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8)
/// type::birthdate,date::1975-03-15
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
// With no sink the reporter only logs, and nothing else in the parse
// can fail, so OutOfMemory is unreachable. An exhaustive switch
// rather than `catch unreachable`: a new error in `parseWith`'s set
// becomes a compile error here instead of a silent assumption.
return parseWith(data, .{}) catch |err| switch (err) {
error.OutOfMemory => unreachable,
};
}
/// `parseProjectionsConfig`, reporting validation problems through
/// `rep`. Only a sink-backed reporter can fail (allocating a finding).
fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
var config = UserConfig{};
const raw = data orelse return config;
if (raw.len == 0) return config;
@ -803,17 +866,28 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
// rule. A single bad value (not in {90,95,99}) is treated as
// "no annotation on this record" and doesn't poison the others.
var annotation_count: u8 = 0;
// Where the first two annotations were, so the "more than one"
// finding can point at both.
var first_annotation_line: u32 = 0;
var second_annotation_line: u32 = 0;
while (true) {
const next = it.next() catch |err| {
// Used to be `catch null`, which dropped the rest of the
// file without a word.
rep.at(@intCast(it.state.line));
try rep.warn("record", "stopped reading ({s}); this and every later record are ignored", .{@errorName(err)});
break;
};
const field_it = next orelse break;
const line: u32 = @intCast(it.state.line);
rep.at(line);
while (it.next() catch null) |field_it| {
const rec = field_it.to(SrfProjection, srf_opts.user_edited) catch |err| {
// Skip the record rather than losing the whole file, but
// name the error: a dropped record reverts that setting to
// its default without saying so. Quiet under
// `zig build test`, where fixtures feed malformed records
// on purpose.
if (!builtin.is_test) {
log.warn("projections.srf: skipping malformed record: {s}", .{@errorName(err)});
}
// its default without saying so.
try rep.warn("record", "skipping malformed record ({s}); its settings revert to defaults", .{@errorName(err)});
continue;
};
switch (rec) {
@ -827,7 +901,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (cap >= 0) {
config.return_cap = cap;
} else {
warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
try rep.warn("return_cap", "return_cap must be >= 0 (got {d}); value ignored", .{cap});
}
}
if (c.horizon) |h| {
@ -836,14 +910,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
saw_horizon = true;
}
if (h == 0) {
log.warn("projections: horizon must be > 0; ignoring record", .{});
try rep.warn("horizon", "horizon must be > 0; value ignored", .{});
} else if (config.horizon_count >= UserConfig.max_horizons) {
log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h });
try rep.warn("horizon", "horizon limit reached ({d}); ignoring extra horizon (value {d})", .{ UserConfig.max_horizons, h });
} else {
config.horizons[config.horizon_count] = h;
if (validRetirementTarget(c.retirement_target)) |conf| {
if (try validRetirementTarget(c.retirement_target, rep)) |conf| {
config.horizon_targets[config.horizon_count] = conf;
annotation_count += 1;
if (annotation_count == 1) first_annotation_line = line;
if (annotation_count == 2) second_annotation_line = line;
}
config.horizon_count += 1;
}
@ -860,14 +936,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
saw_horizon = true;
}
if (age == 0) {
log.warn("projections: horizon_age must be > 0; ignoring record", .{});
try rep.warn("horizon_age", "horizon_age must be > 0; value ignored", .{});
} else if (config.horizon_age_count >= UserConfig.max_horizons) {
log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age });
try rep.warn("horizon_age", "horizon_age limit reached ({d}); ignoring extra horizon_age (value {d})", .{ UserConfig.max_horizons, age });
} else {
config.horizon_ages[config.horizon_age_count] = age;
if (validRetirementTarget(c.retirement_target)) |conf| {
if (try validRetirementTarget(c.retirement_target, rep)) |conf| {
config.horizon_age_targets[config.horizon_age_count] = conf;
annotation_count += 1;
if (annotation_count == 1) first_annotation_line = line;
if (annotation_count == 2) second_annotation_line = line;
}
config.horizon_age_count += 1;
}
@ -880,7 +958,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (amt >= 0) {
config.annual_contribution = amt;
} else {
warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt});
try rep.warn("annual_contribution", "annual_contribution must be >= 0 (got {d}); value ignored", .{amt});
}
}
if (c.contribution_inflation_adjusted) |b| {
@ -890,7 +968,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (amt >= 0) {
config.target_spending = amt;
} else {
warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
try rep.warn("target_spending", "target_spending must be >= 0 (got {d}); value ignored", .{amt});
}
}
if (c.target_spending_inflation_adjusted) |b| {
@ -904,10 +982,10 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
const frac = pct / 100.0;
const cap = max_abs_spending_real_change;
if (frac > cap) {
warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
try rep.warn("spending_change", "spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = cap;
} else if (frac < -cap) {
warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
try rep.warn("spending_change", "spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = -cap;
} else {
config.spending_real_change = frac;
@ -923,7 +1001,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (pct >= 0) {
config.survivor_spending_pct = pct;
} else {
warnUser("projections: survivor_spending_pct must be >= 0 (got {d}); ignoring record", .{pct});
try rep.warn("survivor_spending_pct", "survivor_spending_pct must be >= 0 (got {d}); value ignored", .{pct});
}
}
if (c.max_accumulation_years) |n| {
@ -931,12 +1009,12 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
// A zero-year search ceiling is degenerate (it
// would only ever ask "can I retire today?").
// Almost certainly a typo; keep the default.
warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
try rep.warn("max_accumulation_years", "max_accumulation_years must be > 0; value ignored", .{});
} else if (n > max_configurable_accumulation_years) {
// Respect the intent (the user wants a large
// ceiling) but clamp to keep the search bounded
// and inside the historical data span.
warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
try rep.warn("max_accumulation_years", "max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
config.max_accumulation_years = max_configurable_accumulation_years;
} else {
config.max_accumulation_years = n;
@ -944,7 +1022,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
}
if (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
try rep.warn("benchmark_stock", "benchmark_stock must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_stock_buf.len, sym.len });
} else {
// Copy into our own buffer + length so the value
// outlives both the SRF iterator's backing data
@ -958,7 +1036,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
}
if (c.benchmark_bond) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
try rep.warn("benchmark_bond", "benchmark_bond must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_bond_buf.len, sym.len });
} else {
@memcpy(config.benchmark_bond_buf[0..sym.len], sym);
config.benchmark_bond_len = @intCast(sym.len);
@ -973,15 +1051,15 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
config.birthdates[idx] = b.date;
if (idx >= config.birthdate_count) config.birthdate_count = idx + 1;
} else {
log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
try rep.warn("person", "birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
}
birthdate_seq += 1;
},
.event => |e| {
if (e.start_age == 0) {
log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name});
try rep.warn("start_age", "event '{s}' has start_age 0; ignoring record", .{e.name});
} else if (config.event_count >= UserConfig.max_events) {
log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
try rep.warn("event", "event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
} else {
var ev = LifeEvent{
.start_age = e.start_age,
@ -1006,7 +1084,8 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
// fall back to the default rule. Logged as a warning so the
// user knows their override was ignored.
if (annotation_count > 1) {
warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{});
rep.at(second_annotation_line);
try rep.warn("retirement_target", "retirement_target set on multiple horizons (first on line {d}); ignoring all annotations and using default promotion rule", .{first_annotation_line});
config.horizon_targets = @splat(0);
config.horizon_age_targets = @splat(0);
}
@ -1016,13 +1095,13 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
/// Validate a `retirement_target` SRF value. Returns the value
/// unchanged if it's exactly 90, 95, or 99; returns null otherwise
/// (logged as a warning so the user notices the typo). Used at parse
/// (reported through `rep` so the user notices the typo). Used at parse
/// time; the view-layer `pickPromotedCell` trusts whatever lands in
/// `horizon_targets`.
fn validRetirementTarget(raw: ?u8) ?u8 {
fn validRetirementTarget(raw: ?u8, rep: Reporter) error{OutOfMemory}!?u8 {
const v = raw orelse return null;
if (v == 90 or v == 95 or v == 99) return v;
warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
try rep.warn("retirement_target", "retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
return null;
}
@ -4000,13 +4079,13 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
}
test "validRetirementTarget: 90/95/99 pass, others fail" {
try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90));
try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95));
try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100));
try std.testing.expectEqual(@as(?u8, 90), try validRetirementTarget(90, .{}));
try std.testing.expectEqual(@as(?u8, 95), try validRetirementTarget(95, .{}));
try std.testing.expectEqual(@as(?u8, 99), try validRetirementTarget(99, .{}));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(null, .{}));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(0, .{}));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(85, .{}));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(100, .{}));
}
// ── oldestBirthdate / oldestAge tests ──────────────────────────
@ -4450,3 +4529,231 @@ test "benchmarkSymbols: an override is honoured and outlives the config" {
try std.testing.expectEqualStrings("VTI", pair[0]);
try std.testing.expectEqualStrings("BND", pair[1]);
}
// ── srf_schema.fileCheck: parse-time rules reported through the lint ──
/// Run both lint passes over `data` as projections.srf and return the
/// semantic findings as "line: message" lines, in line order. Caller
/// frees.
fn lintProjections(data: []const u8) ![]u8 {
const allocator = std.testing.allocator;
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
defer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = Date.fromYmd(2026, 1, 1) });
r.sort();
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (r.findings) |f| {
if (f.kind != .semantic) continue;
try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
}
return aw.toOwnedSlice();
}
fn expectProjectionFindings(data: []const u8, want: []const u8) !void {
const got = try lintProjections(data);
defer std.testing.allocator.free(got);
try std.testing.expectEqualStrings(want, got);
}
test "fileCheck: a clean projections.srf reports nothing" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,target_stock_pct:num:80,return_cap:num:30
\\type::config,horizon:num:30,retirement_target:num:95
\\type::config,horizon_age:num:95
\\type::config,spending_change:num:-2,survivor_spending_pct:num:75
\\type::birthdate,date::1975-03-15
\\type::event,name::Social Security,start_age:num:67,amount:num:38400
\\
, "");
}
test "fileCheck: negative amounts are rejected" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,return_cap:num:-5
\\type::config,annual_contribution:num:-100
\\type::config,target_spending:num:-1
\\type::config,survivor_spending_pct:num:-10
\\
,
\\2: return_cap must be >= 0 (got -5); value ignored
\\3: annual_contribution must be >= 0 (got -100); value ignored
\\4: target_spending must be >= 0 (got -1); value ignored
\\5: survivor_spending_pct must be >= 0 (got -10); value ignored
\\
);
}
test "fileCheck: zero horizons and a zero accumulation ceiling are rejected" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,horizon:num:0
\\type::config,horizon_age:num:0
\\type::config,max_accumulation_years:num:0
\\
,
\\2: horizon must be > 0; value ignored
\\3: horizon_age must be > 0; value ignored
\\4: max_accumulation_years must be > 0; value ignored
\\
);
}
test "fileCheck: out-of-range values report the clamp that was applied" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,spending_change:num:20
\\type::config,spending_change:num:-20
\\type::config,max_accumulation_years:num:150
\\
,
\\2: spending_change capped at +10%/yr (got 20%)
\\3: spending_change capped at -10%/yr (got -20%)
\\4: max_accumulation_years capped at 100 (got 150)
\\
);
}
test "fileCheck: benchmark symbols longer than the buffer are rejected" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
\\type::config,benchmark_bond::ABCDEFGHIJKLMNOPQ
\\
,
\\2: benchmark_stock must be 1..16 chars (got 17); value ignored
\\3: benchmark_bond must be 1..16 chars (got 17); value ignored
\\
);
}
test "fileCheck: an invalid retirement_target is ignored" {
try expectProjectionFindings(
\\#!srfv1
\\type::config,horizon:num:30,retirement_target:num:85
\\
,
\\2: retirement_target must be 90, 95, or 99 (got 85); annotation ignored
\\
);
}
test "fileCheck: retirement_target on two horizons points at both" {
// A cross-record rule - the reason this is a `fileCheck` and not a
// per-record `semanticCheck`.
try expectProjectionFindings(
\\#!srfv1
\\type::config,horizon:num:30,retirement_target:num:95
\\type::config,horizon_age:num:90,retirement_target:num:99
\\
,
\\3: retirement_target set on multiple horizons (first on line 2); ignoring all annotations and using default promotion rule
\\
);
}
test "fileCheck: horizon and horizon_age limits" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.writeAll("#!srfv1\n");
for (0..UserConfig.max_horizons + 1) |i| try aw.writer.print("type::config,horizon:num:{d}\n", .{10 + i});
for (0..UserConfig.max_horizons + 1) |i| try aw.writer.print("type::config,horizon_age:num:{d}\n", .{80 + i});
// Line 1 is the header, so the ninth horizon is line 10 and the
// ninth horizon_age is line 19.
try expectProjectionFindings(aw.written(),
\\10: horizon limit reached (8); ignoring extra horizon (value 18)
\\19: horizon_age limit reached (8); ignoring extra horizon_age (value 88)
\\
);
}
test "fileCheck: birthdate beyond the person limit" {
try expectProjectionFindings(
\\#!srfv1
\\type::birthdate,date::1980-01-01,person:num:5
\\
,
\\2: birthdate person index 5 exceeds limit (4); ignoring record
\\
);
}
test "fileCheck: event with start_age 0, and the event limit" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.writeAll("#!srfv1\ntype::event,name::Pension,amount:num:100\n");
for (0..UserConfig.max_events + 1) |i| {
try aw.writer.print("type::event,name::Event {d},start_age:num:{d},amount:num:1\n", .{ i, 60 + i });
}
// Line 2 has no start_age (defaults to 0). Lines 3..18 fill the 16
// slots; line 19 is the seventeenth.
try expectProjectionFindings(aw.written(),
\\2: event 'Pension' has start_age 0; ignoring record
\\19: event limit reached (16); ignoring extra event 'Event 16'
\\
);
}
test "fileCheck: a record that fails to coerce is reported, not silently dropped" {
// `doctor`'s parse-check for this file is structural only, because
// the parser is infallible - so before this, nothing reported it.
try expectProjectionFindings(
\\#!srfv1
\\type::config,horizon::thirty
\\type::confg,horizon:num:30
\\
,
\\2: skipping malformed record (InvalidCharacter); its settings revert to defaults
\\3: skipping malformed record (ActiveTagDoesNotExist); its settings revert to defaults
\\
);
}
test "fileCheck: a stream error is reported instead of truncating the file silently" {
// A directive after data is a hard SRF error; the parser used to
// `catch null` it and quietly drop every record after it.
const got = try lintProjections(
\\#!srfv1
\\type::config,horizon:num:30
\\#!expires=1767225600
\\type::config,target_stock_pct:num:60
\\
);
defer std.testing.allocator.free(got);
try std.testing.expect(std.mem.indexOf(u8, got, "stopped reading (ParseFailed); this and every later record are ignored") != null);
}
test "fileCheck: attaching a sink does not change what the parser produces" {
// The lint runs the real parser; it must not also alter it. Same
// bad input through both modes, compared field by field.
const data =
\\#!srfv1
\\type::config,return_cap:num:-5,target_stock_pct:num:70
\\type::config,horizon:num:0
\\type::config,horizon:num:30,retirement_target:num:95
\\type::config,horizon_age:num:90,retirement_target:num:99
\\type::config,spending_change:num:20
\\type::event,name::Pension,amount:num:100
\\
;
const plain = parseProjectionsConfig(data);
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var sink: srf_lint.Sink = .{ .allocator = arena_state.allocator(), .findings = .empty };
const linted = try parseWith(data, .{ .sink = &sink });
try std.testing.expect(sink.findings.items.len > 0);
try std.testing.expectEqual(plain.target_stock_pct, linted.target_stock_pct);
try std.testing.expectEqual(plain.return_cap, linted.return_cap);
try std.testing.expectEqual(plain.horizon_count, linted.horizon_count);
try std.testing.expectEqualSlices(u16, &plain.horizons, &linted.horizons);
try std.testing.expectEqualSlices(u8, &plain.horizon_targets, &linted.horizon_targets);
try std.testing.expectEqualSlices(u8, &plain.horizon_age_targets, &linted.horizon_age_targets);
try std.testing.expectEqual(plain.spending_real_change, linted.spending_real_change);
try std.testing.expectEqual(plain.event_count, linted.event_count);
}

View file

@ -353,7 +353,7 @@ pub fn compareAccounts(
if (bp.is_cash) {
saw_cash_row = true;
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
pf_shares = portfolio.cashForAccount(as_of, portfolio_acct_name.?);
pf_value = pf_shares;
} else {
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
@ -554,7 +554,7 @@ pub fn compareAccounts(
// the balance is zero. Absence is strong evidence, not proof, and
// `audit` exists to surface things for a human to decide.
if (!saw_cash_row) {
const pf_cash = portfolio.cashForAccount(pa);
const pf_cash = portfolio.cashForAccount(as_of, pa);
if (@abs(pf_cash) > cash_tolerance) {
portfolio_total += pf_cash;
has_discrepancies = true;
@ -1719,3 +1719,38 @@ test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
defer allocator.free(sugg);
try std.testing.expectEqual(@as(usize, 0), sugg.len);
}
test "compareAccounts: a cash lot closed in the portfolio is not flagged portfolio-only" {
// The companion to "cash the broker no longer reports": once the
// user records the close, the lot is gone as of today. It used to
// still be counted by `cashForAccount`, so closing it didn't clear
// the flag - only deleting the line did.
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL", .close_date = Date.fromYmd(2026, 7, 1) },
.{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
};
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FDSCX", 50.67);
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
for (results[0].comparisons) |cmp| {
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
}
}

View file

@ -105,7 +105,7 @@ pub fn compareSchwabSummary(
var expectation: common.AccountValueExpectation = .{};
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_cash = portfolio.cashForAccount(as_of, pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
expectation = common.accountValueExpectation(portfolio, as_of, pa);
}

View file

@ -487,6 +487,14 @@ pub fn portfolioSummary(
var total_realized: f64 = 0;
for (positions) |pos| {
// Realized P&L first, before either skip below. It is already
// known from the closed lots and needs no price - and the
// positions it matters most for are the ones those skips drop:
// a fully sold symbol (`shares == 0`) exists here ONLY to carry
// its realized gain, and used to be discarded along with it.
// Same for a held symbol with no current price.
total_realized += pos.realized_gain_loss;
if (pos.shares <= 0) continue;
const raw_price = prices.get(pos.symbol) orelse continue;
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
@ -494,7 +502,6 @@ pub fn portfolioSummary(
const mv = pos.marketValue(raw_price, is_manual);
total_value += mv;
total_cost += pos.total_cost;
total_realized += pos.realized_gain_loss;
try allocs.append(allocator, .{
.symbol = pos.symbol,
@ -2614,3 +2621,30 @@ test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" {
}
}
}
test "portfolioSummary: realized P&L includes fully sold and unpriced symbols" {
// Regression: the `shares <= 0` and no-price skips ran BEFORE the
// realized sum, so a symbol you had sold entirely - the most common
// source of realized gains - never reached "Realized P&L".
const alloc = std.testing.allocator;
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("HELD", 20.0);
const positions = [_]portfolio_mod.Position{
// Held and priced, with a partial sale behind it.
.{ .symbol = "HELD", .shares = 10, .avg_cost = 10, .total_cost = 100, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 30 },
// Fully sold: exists only to carry its realized gain.
.{ .symbol = "SOLD", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 2, .realized_gain_loss = 500 },
// Held but no current price; its realized loss still counts.
.{ .symbol = "NOPRICE", .shares = 5, .avg_cost = 8, .total_cost = 40, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = -20 },
};
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
defer summary.deinit(alloc);
try std.testing.expectApproxEqAbs(@as(f64, 510), summary.realized_gain_loss, 0.001);
// The skips still apply to everything else: only HELD is valued.
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.total_value, 0.001);
}

92
src/cache/store.zig vendored
View file

@ -25,6 +25,7 @@ const Edgar = @import("../providers/Edgar.zig");
// require millisecond precision to avoid collisions, which a
// caller-provided second-resolution `now_s` couldn't give us.
const Lot = @import("../models/portfolio.zig").Lot;
const ParsedLot = @import("../models/portfolio.zig").ParsedLot;
const LotType = @import("../models/portfolio.zig").LotType;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const OptionsChain = @import("../models/option.zig").OptionsChain;
@ -2303,7 +2304,11 @@ pub fn deserializePortfolioDiag(
// `user_edited` coercion: see `srf_opts.zig` for why hand-edited
// files get different options from cache files. The `catch`
// below still handles genuinely unparseable values.
var lot = fields.to(Lot, srf_opts.user_edited) catch |err| {
//
// Parsed as `ParsedLot`, not `Lot`, so a cash or watch lot may
// omit the fields its type doesn't need; `Lot.fromParsed` then
// applies the per-type rule and names what's missing.
const parsed = fields.to(ParsedLot, srf_opts.user_edited) catch |err| {
if (diags) |d| {
try appendParseDiag(allocator, d, data, line, @errorName(err));
} else if (!builtin.is_test) {
@ -2315,6 +2320,22 @@ pub fn deserializePortfolioDiag(
skipped += 1;
continue;
};
var lot = switch (Lot.fromParsed(parsed)) {
.lot => |l| l,
.missing => |field| {
// `appendParseDiag` formats `msg` into its own string,
// so this one is freed right after.
const msg = try std.fmt.allocPrint(allocator, "{s} is required for {s} lots", .{ field, @tagName(parsed.security_type) });
defer allocator.free(msg);
if (diags) |d| {
try appendParseDiag(allocator, d, data, line, msg);
} else if (!builtin.is_test) {
std.log.warn("portfolio: record at line {d}: {s}; skipping", .{ line, msg });
}
skipped += 1;
continue;
},
};
// Dupe owned strings before iterator.deinit() frees the backing buffer
lot.symbol = try allocator.dupe(u8, lot.symbol);
@ -2740,11 +2761,15 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
var s = Store.init(io, allocator, dir_path);
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
// Bracket the write: `writeSupplement` reads the clock itself, so
// an instant captured only before it can be a second behind the
// one it stamps, which flakes the upper bound below.
const before_s = std.Io.Timestamp.now(io, .real).toSeconds();
var incoming = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
};
s.writeSupplement(Dividend, "TEST", incoming[0..], "tiingo");
const after_s = std.Io.Timestamp.now(io, .real).toSeconds();
const path = try std.fs.path.join(allocator, &.{ dir_path, "TEST", "dividends.srf" });
defer allocator.free(path);
@ -2760,8 +2785,8 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
const spec = DataType.dividends.ttl();
const jitter = @divFloor(spec.seconds * @as(i64, spec.jitter_pct), 100);
const new_expires = it.expires orelse return error.ExpiresMissing;
try std.testing.expect(new_expires - now_s >= spec.seconds - jitter);
try std.testing.expect(new_expires - now_s <= spec.seconds + jitter);
try std.testing.expect(new_expires - before_s >= spec.seconds - jitter);
try std.testing.expect(new_expires - after_s <= spec.seconds + jitter);
}
test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon)" {
@ -4724,3 +4749,62 @@ test "diskStats and cacheKeys agree on the symbol count" {
// still on the disk, so the top-level file still counts toward `files`.
try std.testing.expectEqual(@as(usize, 1), ds.files);
}
test "deserializePortfolioDiag: cash and watch lots may omit what their type doesn't need" {
// The shapes the reference docs show. Before `ParsedLot`, both were
// dropped with FieldNotFoundOnFieldWithoutDefaultValue.
const allocator = std.testing.allocator;
const data =
\\#!srfv1
\\security_type::cash,shares:num:48000,account::Sample Brokerage
\\security_type::watch,symbol::NVDA
\\
;
var diags: ParseDiagnostics = .empty;
defer {
for (diags.items) |d| allocator.free(d);
diags.deinit(allocator);
}
var pf = try deserializePortfolioDiag(allocator, data, &diags);
defer pf.deinit();
try std.testing.expectEqual(@as(usize, 0), diags.items.len);
try std.testing.expectEqual(@as(usize, 2), pf.lots.len);
try std.testing.expectEqual(LotType.cash, pf.lots[0].security_type);
try std.testing.expectEqual(@as(f64, 48000), pf.lots[0].shares);
try std.testing.expectEqual(@as(f64, 1.0), pf.lots[0].open_price);
try std.testing.expectEqual(LotType.watch, pf.lots[1].security_type);
try std.testing.expectEqualStrings("NVDA", pf.lots[1].symbol);
}
test "deserializePortfolioDiag: a stock lot missing open_price is still rejected, by name" {
const allocator = std.testing.allocator;
const data =
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15
\\
;
var diags: ParseDiagnostics = .empty;
defer {
for (diags.items) |d| allocator.free(d);
diags.deinit(allocator);
}
var pf = try deserializePortfolioDiag(allocator, data, &diags);
defer pf.deinit();
try std.testing.expectEqual(@as(usize, 0), pf.lots.len);
try std.testing.expectEqual(@as(usize, 1), diags.items.len);
try std.testing.expect(std.mem.indexOf(u8, diags.items[0], "line 2: open_price is required for stock lots") != null);
}
test "serializePortfolio: output is unchanged for a cash lot the reader filled in" {
// Only the reader was relaxed. A filled-in cash lot writes back with
// its fields spelled out, exactly as `zfin import` always wrote it.
const allocator = std.testing.allocator;
var pf = try deserializePortfolio(allocator, "#!srfv1\nsecurity_type::cash,shares:num:100,account::Sample IRA\n");
defer pf.deinit();
const out = try serializePortfolio(allocator, pf.lots);
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "open_date::1970-01-01") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "open_price:num:1") != null);
}

View file

@ -20,6 +20,10 @@ const contributions = @import("../contributions.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const classification = @import("../../models/classification.zig");
const transaction_log = @import("../../models/transaction_log.zig");
const projections = @import("../../analytics/projections.zig");
const srf_lint = @import("../../srf_lint.zig");
const Date = @import("../../Date.zig");
const srf = @import("srf");
const git = @import("../../git.zig");
@ -657,6 +661,15 @@ fn printLargeLotWarning(
var date_buf: [10]u8 = undefined;
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
// `Date.epoch` means "no date": `zfin import` writes it, a cash lot
// may omit `open_date` altogether, and a `cash_contribution` (a
// deposit into an existing cash lot) has none. Printing 1970-01-01 as
// the day of the contribution would be a lie, and pasting it into
// `transaction_log.srf` would record one - so ask for the date
// instead. `dest_lot::` keeps the literal date: it names the lot,
// must parse, and the transfer matcher ignores its date.
const undated = lot.open_date.eql(Date.epoch);
const transfer_date: []const u8 = if (undated) "<DATE>" else date_str;
const kind_label: []const u8 = switch (lot.security_type) {
.stock => "STOCK",
.cash => "CASH",
@ -671,7 +684,7 @@ fn printLargeLotWarning(
.{ lot.account, kind_label, sym_for_display },
);
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
try out.print(" on {s}\n", .{date_str});
if (undated) try out.writeAll(" (date unknown)\n") else try out.print(" on {s}\n", .{date_str});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
@ -688,7 +701,7 @@ fn printLargeLotWarning(
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, lot.value, lot.account },
.{ transfer_date, lot.value, lot.account },
);
} else {
try cli.printFg(
@ -696,7 +709,7 @@ fn printLargeLotWarning(
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, lot.value, lot.account, lot.symbol, date_str },
.{ transfer_date, lot.value, lot.account, lot.symbol, date_str },
);
}
}
@ -1231,7 +1244,7 @@ pub fn runHygieneCheck(
// (not in a git repo). Threshold is per-account: an account's
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
// filter's built-in default applies.
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
var found_mut = found;
defer found_mut.deinit();
@ -1265,9 +1278,112 @@ pub fn runHygieneCheck(
}
} else |_| {}
// ── Section 8: Config file problems ──
//
// SRF discards a record key that names no field of the target
// struct, silently: `price_dat::2024-01-01` is indistinguishable
// from having omitted the price date. 19 of `Lot`'s 22 fields have
// defaults, so most typos here change behavior with no output at
// all. Rules live with the models (`srf_schema`); `srf_lint` walks
// the records. `zfin doctor` reports the same findings per-file with
// a reference-page pointer; this is the routine-use summary.
//
// Silent when clean.
//
// `as_of` is passed as the lint's `today`: its date rules ("a
// close_date in the future") only make sense against the real
// current day, and audit sets `as_of = ctx.today` unconditionally
// (`commands/audit.zig`) - there is no `--as-of` flag to back-date it.
try printFieldNameSection(io, allocator, portfolio_paths, portfolio_path, as_of, color, out);
try out.print("\n", .{});
}
/// Section 8: field names that no model explains, plus each model's
/// `semanticCheck` rules (e.g. `Lot`'s impossible lifecycle dates).
///
/// Covers every `portfolio*.srf` in the glob plus the portfolio's
/// siblings. `keys.srf` / `theme.srf` are deliberately EXCLUDED - they
/// live under `~/.config/zfin` and configure the TUI, not the
/// portfolio, so they are `doctor`'s business. This section is
/// "portfolio hygiene".
fn printFieldNameSection(
io: std.Io,
allocator: std.mem.Allocator,
portfolio_paths: []const []const u8,
anchor: []const u8,
today: Date,
color: bool,
out: *std.Io.Writer,
) !void {
var header_shown = false;
for (portfolio_paths) |p| {
try lintOne(io, allocator, portfolio_mod.srf_schema, p, today, color, &header_shown, out);
}
const siblings = .{
.{ analysis.srf_schema, "accounts.srf" },
.{ classification.srf_schema, "metadata.srf" },
.{ transaction_log.srf_schema, "transaction_log.srf" },
.{ projections.srf_schema, "projections.srf" },
.{ cli.srf_schema, "watchlist.srf" },
};
inline for (siblings) |pair| {
if (cli.siblingPath(allocator, anchor, pair[1])) |path| {
defer allocator.free(path);
try lintOne(io, allocator, pair[0], path, today, color, &header_shown, out);
} else |_| {}
}
}
/// Lint one file, emitting the shared section header on first finding so
/// a clean run prints nothing at all.
fn lintOne(
io: std.Io,
allocator: std.mem.Allocator,
comptime S: type,
path: []const u8,
today: Date,
color: bool,
header_shown: *bool,
out: *std.Io.Writer,
) !void {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024 * 1024)) catch return;
defer allocator.free(bytes);
var result = srf_lint.check(allocator, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
defer result.deinit();
// Model-owned semantic rules (a second, typed pass - SRF's
// iterators are single-pass, so `to()` cannot share the raw walk).
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
result.sort();
if (result.findings.len == 0) return;
if (!header_shown.*) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Config file problems\n", .{});
header_shown.* = true;
}
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{std.fs.path.basename(path)});
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (result.findings) |f| {
try cli.printFg(out, color, cli.CLR_WARNING, " line {d}: {s}\n", .{ f.first_line, f.describe(&buf) });
}
if (result.truncated) {
try cli.printFg(out, color, cli.CLR_WARNING, " (more suppressed; fix these first)\n", .{});
}
// The authoritative field list, derived from the struct - so unlike
// a doc page it cannot be out of date. Printed whenever there is
// any NAME finding, which is what lets the suggestion matcher stay
// conservative: even with no guess to offer, the user gets the set.
// Skipped for lifecycle-only reports, where it would be noise.
if (result.hasNameFindings()) {
try cli.printFg(out, color, cli.CLR_MUTED, " valid fields:\n", .{});
try srf_lint.writeValidNames(out, result.shape, 8, 76);
}
}
// ── Tests ────────────────────────────────────────────────────
test "accumulatePresent: unions account numbers across calls" {
@ -2202,6 +2318,68 @@ test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opt
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
}
test "runHygieneCheck: a large deposit on a cash_is_contribution account doesn't blank Large new lots" {
// End-to-end regression for the `MissingOpenDate` bug: the deposit
// becomes a `cash_contribution` with no open_date, which used to
// error out of `collectUnmatchedLargeLots` and suppress the whole
// section - taking the unrelated VTI lot down with it.
const allocator = std.testing.allocator;
const io = std.testing.io;
if (!test_git.available(allocator)) return;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
// Committed state: $10k cash on an account whose cash deposits are
// contributions.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data = "#!srfv1\nsecurity_type::cash,shares:num:10000,open_date::2025-01-01,open_price:num:1,account::Sample IRA\n",
});
try tmp.dir.writeFile(io, .{
.sub_path = "accounts.srf",
.data = "#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
});
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "accounts.srf" });
try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" });
// Working copy: a $50k deposit, plus a $25k new stock lot.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data =
\\#!srfv1
\\security_type::cash,shares:num:60000,open_date::2025-01-01,open_price:num:1,account::Sample IRA
\\symbol::VTI,shares:num:100,open_date::2026-05-10,open_price:num:250,account::Sample IRA
\\
,
});
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 5, 11), 1_778_500_000, false, .never, &aw.writer);
const out = aw.written();
const start = std.mem.indexOf(u8, out, "Large new lots") orelse return error.LargeLotSectionMissing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "VTI") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "+$50,000.00 (date unknown)") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "transfer::<DATE>,type::cash,amount:num:50000.00") != null);
}
/// Format a two-account portfolio.srf for the git-history test. Only
/// `shares` varies between revisions, which is enough for
/// findModifiedAccounts to flag the account.
@ -2284,3 +2462,202 @@ test "findLastUpdateTimestamps: resolves an account changed in a non-newest comm
try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
try std.testing.expect(ira_ts < roth_ts);
}
// ── Section 8: unrecognized field names ──
/// Shared harness for the Section 8 integration tests. Writes
/// `portfolio.srf` + `accounts.srf` into a tmpdir, runs the full
/// hygiene check, and returns the whole report for the caller to slice.
///
/// Caller owns the returned bytes.
fn runSection8(
allocator: std.mem.Allocator,
portfolio_srf: []const u8,
accounts_srf: []const u8,
) ![]u8 {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = portfolio_srf });
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = accounts_srf });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
// No API keys / no server -> hermetic.
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);
return allocator.dupe(u8, aw.written());
}
const section8_header = "Config file problems";
test "runHygieneCheck: Section 8 is silent for a clean portfolio" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample Brokerage
\\
,
"#!srfv1\naccount::Sample Brokerage,tax_type::taxable\n",
);
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio hygiene") != null);
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
}
test "runHygieneCheck: Section 8 reports a typo in portfolio.srf with a suggestion" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,price_dat::2025-01-01
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "portfolio.srf") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "price_dat") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'price_date'?") != null);
// The valid-name footer is what makes the conservative matcher
// sufficient, so it must actually be emitted.
try std.testing.expect(std.mem.indexOf(u8, sec, "valid fields:") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "option_type") != null);
}
test "runHygieneCheck: Section 8 covers accounts.srf, not just the portfolio" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample IRA
\\
,
"#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_rothh:num:20\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "accounts.srf") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'tax_mix_roth'?") != null);
}
test "runHygieneCheck: Section 8 flags a field that does not apply to the security_type" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,rate:num:4.25
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "'rate' ignored for security_type stock") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "read only for cd") != null);
}
test "runHygieneCheck: Section 8 flags a future close_date" {
// `runSection8` pins as_of to 2026-01-01, which the lint uses as
// `today`. A close in 2062 is the typo'd-year case.
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "line 2: VTI close_date 2062-03-14 is in the future") != null);
}
test "runHygieneCheck: Section 8 stays silent for a correctly closed lot" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2025-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
}
test "runHygieneCheck: Section 8 omits the valid-field list for date-only findings" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
try std.testing.expect(std.mem.indexOf(u8, out[start..], "valid fields:") == null);
}
test "printLargeLotWarning: an undated lot asks for the date instead of printing 1970-01-01" {
// `Date.epoch` is what import writes and what a cash lot that omits
// `open_date` gets. The suggested transfer record must not carry it.
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
try printLargeLotWarning(&writer, .{
.account = "Sample Brokerage",
.symbol = "",
.security_type = .cash,
.value = 25_000.0,
.open_date = Date.epoch,
}, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "1970") == null);
try std.testing.expect(std.mem.indexOf(u8, output, "(date unknown)") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,type::cash,amount:num:25000.00") != null);
}
test "printLargeLotWarning: an undated stock lot keeps the literal date in dest_lot" {
// dest_lot names the lot and must parse as SYMBOL@YYYY-MM-DD; only
// the transfer's own date is a placeholder.
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
try printLargeLotWarning(&writer, .{
.account = "Sample IRA",
.symbol = "VTI",
.security_type = .stock,
.value = 30_000.0,
.open_date = Date.epoch,
}, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null);
}

View file

@ -1127,14 +1127,27 @@ pub fn resolveAsOfOrExplain(
// ── Watchlist loading ────────────────────────────────────────
/// One record from `watchlist.srf`.
///
/// Named (rather than declared inline in `loadWatchlist`) so
/// `srf_schema` below can derive the valid-field set from it - an
/// anonymous struct would leave the lint with a hand-copied name list
/// that could drift from the parser.
pub const WatchEntry = struct { symbol: []const u8 };
/// Schema contract for `watchlist.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = WatchEntry;
pub const file_label: []const u8 = "watchlist.srf";
pub const doc_path: []const u8 = "reference/config/watchlist-srf.md";
};
/// Load a watchlist SRF file containing symbol records.
/// Returns owned symbol strings. Returns null if file missing or empty.
pub fn loadWatchlist(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null;
defer allocator.free(file_data);
const WatchEntry = struct { symbol: []const u8 };
var reader = std.Io.Reader.fixed(file_data);
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null;
defer it.deinit();

File diff suppressed because it is too large Load diff

View file

@ -32,11 +32,15 @@ const fmt = cli.fmt;
const Config = zfin.Config;
const Lot = @import("../models/portfolio.zig").Lot;
const portfolio_mod = @import("../models/portfolio.zig");
const srf_lint = @import("../srf_lint.zig");
const Date = @import("../Date.zig");
const cache = @import("../cache/store.zig");
const freshness = @import("../cache/freshness.zig");
const classification = @import("../models/classification.zig");
const analysis = @import("../analytics/analysis.zig");
const projections = @import("../analytics/projections.zig");
const Journal = @import("../data/Journal.zig");
const transaction_log = @import("../models/transaction_log.zig");
const imported_values = @import("../data/imported_values.zig");
const history = @import("../history.zig");
@ -514,6 +518,66 @@ fn checkSrfFile(
}
}
/// Lint one user-authored SRF file's FIELD NAMES, appending one `.warn`
/// row per rolled-up finding plus a pointer to the reference page.
///
/// Distinct from the `checkSrfFile` row above it, which answers "does
/// this file parse". A file can parse perfectly and still be wrong:
/// SRF discards a key that names no field of the target struct without
/// any error, so `price_dat::2024-01-01` is indistinguishable from
/// having omitted the price date entirely. See `src/srf_lint.zig`.
///
/// Silent when the file is absent, unreadable, or clean - its presence
/// and parseability are already reported by the row above, and
/// repeating that here would double every message.
///
/// `.warn`, never `.fail`: a typo is a nudge, and `doctor`'s exit code
/// is reserved for a file that exists but cannot be parsed at all.
fn appendFieldNameChecks(
io: std.Io,
arena: std.mem.Allocator,
checks: *std.ArrayList(Check),
comptime S: type,
path: []const u8,
today: Date,
) !void {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch return;
var result = srf_lint.check(arena, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
defer result.deinit();
// Model-owned semantic rules (a second, typed pass - SRF's
// iterators are single-pass, so `to()` cannot share the raw walk).
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
result.sort();
if (result.findings.len == 0) return;
const base = std.fs.path.basename(path);
for (result.findings) |f| {
// `describe` copies into arena memory, which outlives
// `result.deinit()` below; `f.detail` would not.
const buf = try arena.alloc(u8, srf_lint.Finding.describe_max);
try checks.append(arena, .{
.status = .warn,
.label = try std.fmt.allocPrint(arena, " {s}:{d}", .{ base, f.first_line }),
.detail = f.describe(buf),
});
}
if (result.truncated) {
try checks.append(arena, .{
.status = .warn,
.label = try std.fmt.allocPrint(arena, " {s}", .{base}),
.detail = "more findings suppressed; fix these first",
});
}
// The reference page is guaranteed complete - `srf_lint`'s doc-sync
// test fails the build if a model field is missing from it - so
// pointing there is sound advice rather than a shrug.
try checks.append(arena, .{
.status = .info,
.label = " field reference",
.detail = try std.fmt.allocPrint(arena, "docs/{s}", .{S.doc_path}),
});
}
/// Join a sibling filename onto the anchor portfolio's directory.
// ── run ───────────────────────────────────────────────────────
@ -556,6 +620,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
for (pf.paths) |rp| {
const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
try checks.append(arena, c);
try appendFieldNameChecks(io, arena, &checks, portfolio_mod.srf_schema, rp.path, ctx.today);
}
}
@ -564,6 +629,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "accounts.srf", try cli.siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, analysis.srf_schema, try cli.siblingPath(arena, a, "accounts.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "accounts.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -575,6 +641,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "metadata.srf", try cli.siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, classification.srf_schema, try cli.siblingPath(arena, a, "metadata.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "metadata.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -586,6 +653,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "transaction_log.srf", try cli.siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, transaction_log.srf_schema, try cli.siblingPath(arena, a, "transaction_log.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "transaction_log.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -594,6 +662,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
}
}
try checks.append(arena, checkSrfFile(io, arena, "projections.srf", try cli.siblingPath(arena, a, "projections.srf"), .optional, validateSrf));
try appendFieldNameChecks(io, arena, &checks, projections.srf_schema, try cli.siblingPath(arena, a, "projections.srf"), ctx.today);
// `watchlist.srf` has no parse-check row of its own -
// `loadWatchlist` degrades to null - but its one field is
// as typo-able as any other.
try appendFieldNameChecks(io, arena, &checks, cli.srf_schema, try cli.siblingPath(arena, a, "watchlist.srf"), ctx.today);
try appendFieldNameChecks(io, arena, &checks, Journal.srf_schema, try cli.siblingPath(arena, a, "acknowledgments.srf"), ctx.today);
// imported_values.srf and the snapshots both live under
// <portfolio_dir>/history/, NOT directly beside the
// portfolio file.
@ -609,6 +683,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// keys.srf / theme.srf live under $HOME/.config/zfin.
try checks.append(arena, checkUserConfigFiles(io, arena, config, .keys));
try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme));
if (userConfigPath(arena, config, "keys.srf")) |p| {
try appendFieldNameChecks(io, arena, &checks, keybinds.srf_schema, p, ctx.today);
}
if (userConfigPath(arena, config, "theme.srf")) |p| {
try appendFieldNameChecks(io, arena, &checks, theme.srf_schema, p, ctx.today);
}
try sections.append(arena, .{ .title = "Files", .checks = checks.items });
}
@ -729,10 +809,28 @@ fn checkPortfolioFile(io: std.Io, arena: std.mem.Allocator, path: []const u8, al
if (err == error.FileNotFound) return .{ .status = .warn, .label = "portfolio.srf", .detail = "not found" };
return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
};
const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
// Ask for diagnostics, not just the lot count. A record that fails
// to coerce is SKIPPED, not fatal, so the plain
// `deserializePortfolio` reports `.ok` with a quietly-smaller
// number - and nobody has a baseline for "how many lots should
// there be". Reporting the skip count is the only way a dropped
// record is visible at all.
var diags: cache.ParseDiagnostics = .empty;
const pf = cache.deserializePortfolioDiag(arena, bytes, &diags) catch |err| {
return .{ .status = .fail, .label = path, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
};
try all_lots.appendSlice(arena, pf.lots);
if (diags.items.len > 0) {
return .{
.status = .warn,
.label = path,
.detail = std.fmt.allocPrint(
arena,
"{d} lots; {d} record(s) SKIPPED - {s}",
.{ pf.lots.len, diags.items.len, diags.items[0] },
) catch "records skipped",
};
}
return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
}
@ -807,6 +905,15 @@ const UserConfigKind = enum { keys, theme };
/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader.
/// `$HOME/.config/zfin/<filename>`, or null when HOME is unset.
/// Mirrors the path `checkUserConfigFiles` builds, so the field-name
/// lint reads the same file the parse-check reported on.
fn userConfigPath(arena: std.mem.Allocator, config: Config, filename: []const u8) ?[]const u8 {
const home = if (config.environ_map) |em| em.get("HOME") else null;
if (home == null) return null;
return std.fs.path.join(arena, &.{ home.?, ".config", "zfin", filename }) catch null;
}
fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
const filename = switch (kind) {
.keys => "keys.srf",
@ -1617,3 +1724,182 @@ test "checkCandleFreshness: an untracked laggard does not warn" {
const c = try checkCandleFreshness(arena, &store, &tracked, &.{}, now_s);
try testing.expectEqual(Status.ok, c.status);
}
// ── appendFieldNameChecks ──
/// Fixed `today` so the date rules' verdicts don't drift with the clock.
const fixed_today = Date.fromYmd(2026, 1, 1);
/// Write `data` to a tmpdir and collect the field-name checks it
/// produces for schema `S`. Caller owns nothing - everything lives in
/// the arena the caller passes.
fn fieldChecksFor(
arena: std.mem.Allocator,
comptime S: type,
filename: []const u8,
data: []const u8,
) !std.ArrayList(Check) {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = filename, .data = data });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const path = try std.fs.path.join(arena, &.{ path_buf[0..dir_len], filename });
var checks: std.ArrayList(Check) = .empty;
try appendFieldNameChecks(io, arena, &checks, S, path, fixed_today);
return checks;
}
test "appendFieldNameChecks: clean file adds nothing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1\n",
);
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
}
test "appendFieldNameChecks: missing file adds nothing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var checks: std.ArrayList(Check) = .empty;
try appendFieldNameChecks(std.testing.io, arena, &checks, portfolio_mod.srf_schema, "/nonexistent/portfolio.srf", fixed_today);
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
}
test "appendFieldNameChecks: a typo warns and points at the reference page" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01\n",
);
// One finding row plus the reference-page pointer.
try std.testing.expectEqual(@as(usize, 2), checks.items.len);
try std.testing.expectEqual(Status.warn, checks.items[0].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].label, "portfolio.srf:2") != null);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].detail, "did you mean 'price_date'?") != null);
// `.info`, not `.warn` - the pointer is not itself a problem.
try std.testing.expectEqual(Status.info, checks.items[1].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[1].detail, "portfolio-srf.md") != null);
}
test "appendFieldNameChecks: never returns .fail, so a typo cannot change the exit code" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// One of every finding kind the walker can produce.
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
\\#!srfv1
\\shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01
\\shares:num:1,open_date::2024-01-01,open_price:num:1,Account::X
\\shares:num:1,open_date::2024-01-01,open_price:num:1,rate:num:5
\\shares:num:1,open_date::2024-01-01,open_price:num:1,split_factor:num:2
\\shares:num:1,open_date::2024-01-01,open_price:num:1,nonsense::x
\\
,
);
try std.testing.expect(checks.items.len > 1);
for (checks.items) |c| {
try std.testing.expect(c.status != .fail);
}
// And `countByStatus` agrees, which is what gates the exit code.
const sections = [_]Section{.{ .title = "Files", .checks = checks.items }};
try std.testing.expectEqual(@as(usize, 0), countByStatus(&sections, .fail));
}
test "checkPortfolioFile: a skipped record warns instead of quietly lowering the count" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Second record omits `open_price`, which has no default - SRF
// cannot coerce it, so the loader drops the record. Before this
// check asked for diagnostics the report said `[OK] 1 lots`.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data =
\\#!srfv1
\\symbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1
\\symbol::SPY,shares:num:2,open_date::2024-01-02
\\
,
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const path = try std.fs.path.join(arena, &.{ path_buf[0..dir_len], "portfolio.srf" });
var lots: std.ArrayList(Lot) = .empty;
const c = try checkPortfolioFile(io, arena, path, &lots);
try std.testing.expectEqual(Status.warn, c.status);
try std.testing.expect(std.mem.indexOf(u8, c.detail, "SKIPPED") != null);
try std.testing.expectEqual(@as(usize, 1), lots.items.len);
}
// ── userConfigPath ──
test "userConfigPath: builds the $HOME/.config/zfin path" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var env = try std.testing.environ.createMap(std.testing.allocator);
defer env.deinit();
try env.put("HOME", "/home/placeholder");
const p = userConfigPath(arena, .{ .cache_dir = "/tmp/placeholder-cache", .environ_map = &env }, "keys.srf");
try std.testing.expectEqualStrings("/home/placeholder/.config/zfin/keys.srf", p.?);
}
test "userConfigPath: null without an environment, so the lint is skipped rather than guessing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
// No environ map at all - the same branch a HOME-less environment
// takes. Returning null means `run` skips the keys/theme lint
// instead of inventing a path.
try std.testing.expectEqual(
@as(?[]const u8, null),
userConfigPath(arena_state.allocator(), .{ .cache_dir = "/tmp/placeholder-cache" }, "theme.srf"),
);
}
test "appendFieldNameChecks: lot lifecycle rules reach doctor as warnings" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1,close_date::2062-03-14,close_price:num:2\n",
);
// One finding plus the reference-page pointer.
try std.testing.expectEqual(@as(usize, 2), checks.items.len);
try std.testing.expectEqual(Status.warn, checks.items[0].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].detail, "close_date 2062-03-14 is in the future") != null);
}

View file

@ -415,7 +415,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var prior_lookup_opt: ?PriorLotsLookup = null;
defer if (prior_lookup_opt) |*p| p.deinit();
if (prior_portfolio_opt) |pf| {
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots, ctx.today);
}
// ── Synthesize lots ───────────────────────────────────────
@ -585,7 +585,7 @@ const PriorLotsLookup = struct {
map: std.StringHashMap(*const portfolio_mod.Lot),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup {
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
errdefer {
var it = map.keyIterator();
@ -594,12 +594,14 @@ const PriorLotsLookup = struct {
}
for (lots) |*lot| {
// Skip closed lots: they shouldn't anchor a re-import's
// Skip sold lots: they shouldn't anchor a re-import's
// open_date for a position the brokerage shows as held.
// (Today's import doesn't write `close_date`/`close_price`
// anyway, so this is also defensive against hand-edited
// closed lots in the file.)
if (lot.close_date != null) continue;
// closed lots in the file.) "Sold", not "ended": import
// carries `maturity_date` forward, so a brokered CD
// re-imported on its maturity day must still anchor.
if (lot.isSoldAsOf(today)) continue;
// Cash lots have no symbol/account-meaningful identity
// for matching across imports - skip.
if (lot.security_type == .cash) continue;
@ -994,7 +996,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
.note = "imported fidelity 2024-01-15",
},
};
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior_lookup.deinit();
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
@ -1135,7 +1137,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
.note = "imported fidelity 2024-06-01",
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
// Export shows 120 shares now (user bought more) at avg
@ -1188,7 +1190,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
.drip = true,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1258,7 +1260,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1320,7 +1322,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1360,7 +1362,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1407,7 +1409,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
// Export only shows AAPL (user sold all GOOG).
@ -1440,7 +1442,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
.security_type = .cash,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);

View file

@ -364,6 +364,7 @@ pub fn display(
summary.unrealized_gain_loss,
watch_symbols,
watch_prices,
as_of,
);
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
@ -436,10 +437,10 @@ pub fn display(
// Lot detail rows (always expanded for CLI)
if (is_multi) {
// Check if any lots are DRIP
// Check if any lots go into the DRIP summary
var has_drip = false;
for (lots_for_sym.items) |lot| {
if (lot.drip) {
if (fmt.isDripSummarized(as_of, lot)) {
has_drip = true;
break;
}
@ -448,13 +449,13 @@ pub fn display(
if (!has_drip) {
// No DRIP: show all individually
for (lots_for_sym.items) |lot| {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
}
} else {
// Show non-DRIP lots individually
// Show every lot the summary doesn't cover individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
if (!fmt.isDripSummarized(as_of, lot)) {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
}
}
@ -605,8 +606,10 @@ pub fn display(
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
try cli.reset(out, color);
// Held lots only, so the rows add up to the TOTAL below.
for (portfolio.lots) |lot| {
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
@ -629,8 +632,10 @@ pub fn display(
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
try cli.reset(out, color);
// Held lots only, so the rows add up to the TOTAL below.
for (portfolio.lots) |lot| {
if (lot.security_type != .illiquid) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
}
@ -683,7 +688,7 @@ pub fn display(
/// Render one lot-detail row under its position row.
///
/// `effective_price` is the LOT's price, not its position's: pass
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
/// `views.effectivePriceFor(allocations, lot, as_of)`, which applies the lot's
/// `price_ratio` and resolves `close_price`. Passing an
/// `Allocation.current_price` straight through is the bug this
/// parameter name exists to prevent - that price is the raw base-ticker
@ -1471,7 +1476,7 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
};
const no_allocs: []const zfin.valuation.Allocation = &.{};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
@ -1517,11 +1522,11 @@ test "printLotRow: a ratio'd lot values off its effective price and shows it" {
.unrealized_return = 0.875,
}};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
const eff_price = views.effectivePriceFor(&allocs, lots[0], zfin.Date.fromYmd(2026, 6, 1));
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
const out = w.buffered();

View file

@ -107,6 +107,19 @@ const JournalRecord = union(enum) {
note: NoteRecord,
};
/// Schema contract for `acknowledgments.srf`. See
/// `srf_lint.validateSchema`.
///
/// `Acknowledgment` does NOT redeclare the `type` tag field, unlike
/// `projections.srf`'s variants - SRF's `to()` consumes the tag before
/// recursing into the variant, so both spellings are legal. The shape
/// builder accepts `type::` for every variant either way.
pub const srf_schema = struct {
pub const Record = JournalRecord;
pub const file_label: []const u8 = "acknowledgments.srf";
pub const doc_path: []const u8 = "reference/config/acknowledgments-srf.md";
};
/// In-memory ack with its notes already grouped. Built by `load`;
/// consumed by callers that want "the ack and its reasoning together."
pub const Entry = struct {

View file

@ -742,10 +742,19 @@ pub const DripAggregation = struct {
/// Aggregate DRIP lots into short-term and long-term buckets.
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
/// Is `lot` folded into the DRIP ST/LT summary rather than shown as its
/// own row? Only held DRIP lots are: a sold one is a closed lot like
/// any other, so it gets an individual row with its closed status, and
/// the summary's share count stays what you actually hold. The one test
/// for the aggregator and both renderers (CLI `portfolio`, TUI tab).
pub fn isDripSummarized(as_of: Date, lot: Lot) bool {
return lot.drip and lot.lotIsOpenAsOf(as_of);
}
pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
var result: DripAggregation = .{};
for (lots) |lot| {
if (!lot.drip) continue;
if (!isDripSummarized(as_of, lot)) continue;
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT");
const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
bucket.lot_count += 1;
@ -1786,3 +1795,31 @@ test "fmtSharpeOpt: asterisk and decimals" {
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
}
test "aggregateDripLots: sold DRIP lots stay out of the summary" {
// The summary is a count of what you hold. A sold DRIP lot used to
// be folded in, inflating shares and cost; it now gets its own
// closed row instead (`isDripSummarized`).
const as_of = Date.fromYmd(2026, 1, 1);
const lots = [_]Lot{
.{ .symbol = "VTI", .shares = 1.5, .open_date = Date.fromYmd(2023, 3, 1), .open_price = 200, .drip = true },
.{ .symbol = "VTI", .shares = 2.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 210, .drip = true, .close_date = Date.fromYmd(2025, 12, 1), .close_price = 280 },
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 190 },
};
const agg = aggregateDripLots(as_of, &lots);
try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
try std.testing.expectApproxEqAbs(@as(f64, 1.5), agg.lt.shares, 0.001);
try std.testing.expect(agg.st.isEmpty());
}
test "isDripSummarized: held DRIP only" {
const as_of = Date.fromYmd(2026, 1, 1);
const held: Lot = .{ .symbol = "VTI", .shares = 1, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .drip = true };
try std.testing.expect(isDripSummarized(as_of, held));
var sold = held;
sold.close_date = Date.fromYmd(2025, 6, 1);
try std.testing.expect(!isDripSummarized(as_of, sold));
var not_drip = held;
not_drip.drip = false;
try std.testing.expect(!isDripSummarized(as_of, not_drip));
}

View file

@ -676,6 +676,21 @@ pub fn commitTimestamp(
return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed;
}
/// The UTC calendar day `ref` was committed (committer date, `%ct`).
///
/// The one place a commit becomes a `Date`: callers that need "which
/// day was this commit" should use this rather than pairing
/// `commitTimestamp` with `Date.fromEpoch` themselves.
pub fn commitDate(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
ref: []const u8,
) Error!Date {
return Date.fromEpoch(try commitTimestamp(io, allocator, env, root, ref));
}
/// Resolve a before/after commit range for diffing `repo.rel_path`.
///
/// Three modes selected by `since` / `until`:
@ -1044,6 +1059,19 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
}
test "commitDate is the committer day of commitTimestamp" {
const allocator = std.testing.allocator;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
const ts = commitTimestamp(std.testing.io, allocator, &env, info.root, "HEAD") catch return;
const d = try commitDate(std.testing.io, allocator, &env, info.root, "HEAD");
try std.testing.expect(d.eql(Date.fromEpoch(ts)));
}
test "commitAtOrBeforeDate returns null for date before repo existed" {
const allocator = std.testing.allocator;
var env = gitTestEnv(allocator);

View file

@ -53,6 +53,13 @@ pub const ClassificationEntry = struct {
splits_current_through: ?Date = null,
};
/// Schema contract for `metadata.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = ClassificationEntry;
pub const file_label: []const u8 = "metadata.srf";
pub const doc_path: []const u8 = "reference/config/metadata-srf.md";
};
/// Parsed classification data for the entire portfolio.
pub const ClassificationMap = struct {
entries: []ClassificationEntry,

View file

@ -3,6 +3,7 @@ const Date = @import("../Date.zig");
const Candle = @import("candle.zig").Candle;
const split = @import("split.zig");
const Split = split.Split;
const srf_lint = @import("../srf_lint.zig");
// ── Pricing model ────────────────────────────────────────────
//
@ -117,7 +118,7 @@ const Split = split.Split;
// a merged group.
//
// So: per-lot display sites MUST price through
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot, as_of)`,
// which resolves `close_price`, discriminates on `price_ratio`, and
// applies the ratio via `Lot.effectivePrice`. Never read
// `Allocation.current_price` into a per-lot calculation, and never
@ -366,30 +367,66 @@ pub const Lot = struct {
return self.lotIsOpenAsOf(as_of);
}
/// Was the lot held at end-of-day on `as_of`?
// ── Lot lifecycle: THE definition of open, sold, and ended ──
//
// Every "is this lot closed?" question in zfin is one of these
// three. Nothing outside this struct reads `close_date`,
// `close_price` or `maturity_date` to decide liveness - that was
// how three different definitions of "closed" came to disagree
// (holdings went by date, `contributions` and `import` by whether
// `close_date` was present at all, lot rows by `close_price`). The
// AGENTS.md grep rule keeps it that way.
//
// End-of-day semantics throughout: a date equal to `as_of` has
// already happened. They are deliberately different questions:
//
// hasMaturedAsOf `maturity_date` arrived (options, CDs).
// isSoldAsOf a SALE happened. `close_date` only. What
// `contributions` needs: an option that expired
// and is then recorded with a close is a sale, but
// it had already ended at maturity, so testing
// "ended" would never see the transition.
// hasEndedAsOf sold OR matured. The lot is over, whether or
// not it was ever bought (a not-yet-bought lot
// hasn't ended).
// lotIsOpenAsOf held: bought, and not ended.
/// Has a sale happened on or before `as_of`? True once `close_date`
/// arrives; maturity doesn't count.
pub fn isSoldAsOf(self: Lot, as_of: Date) bool {
const cd = self.close_date orelse return false;
return !as_of.lessThan(cd);
}
/// Has `maturity_date` arrived (options, CDs)? A sale doesn't count.
pub fn hasMaturedAsOf(self: Lot, as_of: Date) bool {
const mat = self.maturity_date orelse return false;
return !as_of.lessThan(mat);
}
/// Is the lot over as of `as_of` - sold, or matured?
pub fn hasEndedAsOf(self: Lot, as_of: Date) bool {
return self.isSoldAsOf(as_of) or self.hasMaturedAsOf(as_of);
}
/// Was the lot held at end-of-day on `as_of`? Bought, and not
/// ended (see `hasEndedAsOf`).
///
/// Used by historical snapshot backfill (`zfin snapshot --as-of`)
/// where "open" must be evaluated against the target date rather
/// than wall-clock today. `isOpen()` delegates to this with
/// today as `as_of`.
///
/// End-of-day semantics (see tests):
/// - `open_date > as_of` -> not yet bought -> false
/// - `close_date` on/before as_of -> sold that day or earlier -> false
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
/// - otherwise -> true
/// than wall-clock today. `isOpen()` delegates to this.
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
// Not yet bought on `as_of`.
if (as_of.lessThan(self.open_date)) return false;
// Sold on or before `as_of`.
if (self.close_date) |cd| {
if (!as_of.lessThan(cd)) return false;
}
// Matured on or before `as_of` (options, CDs).
if (self.maturity_date) |mat| {
if (!as_of.lessThan(mat)) return false;
}
return true;
return !self.hasEndedAsOf(as_of);
}
/// The price the lot was sold at, once the sale has happened.
/// Null before `close_date` arrives even when `close_price` is set:
/// a close price on a lot that's still held is not a price at all.
pub fn closePriceAsOf(self: Lot, as_of: Date) ?f64 {
if (!self.isSoldAsOf(as_of)) return null;
return self.close_price;
}
/// Cost basis: RAW shares x RAW open_price. Split-invariant
@ -427,12 +464,13 @@ pub const Lot = struct {
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
}
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
/// Returns null if the lot is still open. Stays on RAW shares - a
/// closed lot is a completed round-trip whose recorded open/close are
/// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
pub fn realizedGainLoss(self: Lot) ?f64 {
const cp = self.close_price orelse return null;
/// Realized gain/loss once the lot is sold: shares * (close_price -
/// open_price). Null until the sale happens, or when it was recorded
/// without a `close_price`. Stays on RAW shares - a sold lot is a
/// completed round-trip whose recorded open/close are consistent;
/// `enrichSplits` leaves closed lots at `split_factor 1.0`.
pub fn realizedGainLossAsOf(self: Lot, as_of: Date) ?f64 {
const cp = self.closePriceAsOf(as_of) orelse return null;
return self.shares * (cp - self.open_price);
}
@ -441,11 +479,281 @@ pub const Lot = struct {
return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
}
pub fn returnPct(self: Lot, current_price: f64) f64 {
/// Return on the lot: against the sale price once sold, otherwise
/// against `current_price`.
pub fn returnPct(self: Lot, current_price: f64, as_of: Date) f64 {
if (self.open_price == 0) return 0;
const price = if (self.close_price) |cp| cp else current_price;
const price = self.closePriceAsOf(as_of) orelse current_price;
return (price / self.effectiveOpenPrice()) - 1.0;
}
/// Build a `Lot` from a parsed record, applying the one rule for
/// which fields a lot may omit. Returns the name of the first
/// missing field when the lot's type needs it.
///
/// - cash: `open_date` and `open_price` may be omitted and become
/// `Date.epoch` and 1.0 - exactly what `zfin import` writes for
/// cash, where a share IS a dollar. `shares` (the balance) is
/// still required.
/// - watch: all three may be omitted (epoch, 0, 0). A watch lot
/// is a price to track, and `Portfolio.watchSymbols` reads only
/// its `symbol`.
/// - stock, option, cd, illiquid: all three are required. A
/// default here would be silently wrong - cost 0 reads as a 100%
/// gain and values a new contribution at $0, and an epoch date
/// makes every split since 1970 look unhandled.
///
/// Why not plain defaults on `Lot`: the SRF writer omits a field
/// equal to its default, so `zfin import` (which writes epoch dates
/// on stock lots) would then produce files this rule rejects. Only
/// the reader is relaxed; `Lot`, the writer, and every `Lot{...}`
/// literal keep the fields required.
pub fn fromParsed(p: ParsedLot) union(enum) { lot: Lot, missing: []const u8 } {
const fill: ?struct { shares: ?f64, open_date: Date, open_price: f64 } = switch (p.security_type) {
.cash => .{ .shares = null, .open_date = Date.epoch, .open_price = 1.0 },
.watch => .{ .shares = 0, .open_date = Date.epoch, .open_price = 0 },
.stock, .option, .cd, .illiquid => null,
};
// SAFETY: every field is assigned by the loop below, or the
// function returns `.missing` before `lot` is read.
var lot: Lot = undefined;
inline for (std.meta.fields(Lot)) |f| {
if (comptime isTypeDependent(f.name)) {
if (@field(p, f.name)) |v| {
@field(lot, f.name) = v;
} else {
const fallback: ?f.type = if (fill) |d| @field(d, f.name) else null;
@field(lot, f.name) = fallback orelse return .{ .missing = f.name };
}
} else {
@field(lot, f.name) = @field(p, f.name);
}
}
return .{ .lot = lot };
}
};
/// `Lot` fields that only some lot types need, and which are therefore
/// optional in `ParsedLot`. See `Lot.fromParsed` for the rule.
const type_dependent_fields = [_][]const u8{ "shares", "open_date", "open_price" };
fn isTypeDependent(comptime name: []const u8) bool {
for (type_dependent_fields) |n| {
if (std.mem.eql(u8, n, name)) return true;
}
return false;
}
/// `Lot` as read from a hand-edited portfolio file: the same fields,
/// except the `type_dependent_fields` are optional so a record that
/// legitimately omits them (a cash or watch lot) still coerces.
/// `Lot.fromParsed` converts it back.
///
/// DERIVED from `Lot` at comptime rather than written out, so the two
/// cannot drift: a field added to `Lot` appears here automatically,
/// with the same type and default, and `srf_lint`'s valid-name set
/// (taken from `Lot`) stays the set this actually parses.
pub const ParsedLot = blk: {
const fields = std.meta.fields(Lot);
for (type_dependent_fields) |n| {
if (!@hasField(Lot, n)) @compileError("type_dependent_fields names `" ++ n ++ "`, which is not a field of Lot");
}
var names: [fields.len][]const u8 = undefined;
var types: [fields.len]type = undefined;
var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined;
for (fields, 0..) |f, i| {
names[i] = f.name;
if (isTypeDependent(f.name)) {
const none: ?f.type = null;
types[i] = ?f.type;
attrs[i] = .{ .default_value_ptr = &none };
} else {
types[i] = f.type;
attrs[i] = .{ .default_value_ptr = f.default_value_ptr };
}
}
break :blk @Struct(.auto, null, &names, &types, &attrs);
};
/// Schema contract for `portfolio.srf`, consumed by `srf_lint` and
/// rendered by `zfin doctor` / `zfin audit`.
///
/// Lives here rather than in `srf_lint.zig` on purpose: rules kept away
/// from the fields they describe drift from them. `field_rules` is
/// checked for exhaustiveness over `std.meta.fields(Lot)` at comptime
/// in both directions, so adding a field to `Lot` fails the build until
/// it is classified here, and renaming one fails the build too.
pub const srf_schema = struct {
pub const Record = Lot;
pub const file_label: []const u8 = "portfolio.srf";
pub const doc_path: []const u8 = "reference/config/portfolio-srf.md";
/// Fields deliberately absent from the reference doc. Everything
/// else must appear there or the doc-sync test fails.
pub const undocumented = [_][]const u8{
// DERIVED by `enrichSplits`; documenting it would invite
// hand-editing, which `field_rules` flags below.
"split_factor",
};
pub const scope_discriminator: []const u8 = "security_type";
/// Which `LotType`s each field is actually read for.
///
/// A `.only` entry is a claim that the value is SILENTLY IGNORED
/// for every other type, so each one cites the gate that proves it.
/// When a field's reach is ambiguous the entry is deliberately
/// permissive (no `.only`) - a missed nudge costs nothing, while a
/// false "zfin ignores this" would send someone chasing a
/// non-existent bug.
pub const field_rules = [_]srf_lint.Rule{
.{ .name = "symbol" },
.{ .name = "shares" },
.{ .name = "open_date" },
.{ .name = "open_price" },
.{ .name = "close_date" },
.{ .name = "close_price" },
.{ .name = "note" },
.{ .name = "label" },
.{ .name = "account" },
.{ .name = "security_type" },
// Read ungated by `lotIsOpenAsOf` (this file, "Matured on or
// before `as_of`"), so a maturity on a stock lot really does
// close it. NOT option/cd-only despite the doc comment.
.{ .name = "maturity_date" },
// CD yield. Read only by `analytics/reconcile/common.zig`'s
// CD-allowance math and the CD table's rate column in
// `views/portfolio_sections.zig`.
.{ .name = "rate", .only = &.{"cd"} },
// Dividend-reinvestment grouping is a stock-table concern, but
// `commands/contributions.zig`'s lot differ reads it for any
// type. Permissive.
.{ .name = "drip" },
// Pricing fields. `analytics/valuation.zig`'s
// `buildFallbackPrices` gates manual prices to `.stock`, but
// `commands/audit.zig`'s reconcile price map reads `lot.price`
// ungated - so a manual price on a non-stock lot is not
// reliably ignored. Permissive until those two agree.
.{ .name = "ticker" },
.{ .name = "price" },
.{ .name = "price_date" },
.{ .name = "price_ratio" },
// Populated by `enrichSplits`, never by hand.
.{ .name = "split_factor", .derived = true },
// Option mechanics. Every read is behind an `.option` gate:
// `models/option.zig`'s lot matcher, `analytics/valuation.zig`'s
// covered-call detection, and the three `@abs(shares) *
// open_price * multiplier` sites (this file's
// `totalOptionCostAsOf`, `analytics/analysis.zig`, and
// `commands/snapshot.zig`).
.{ .name = "underlying", .only = &.{"option"} },
.{ .name = "strike", .only = &.{"option"} },
.{ .name = "multiplier", .only = &.{"option"} },
.{ .name = "option_type", .only = &.{"option"} },
};
/// Lot lifecycle rules - dates and close fields that parse fine but
/// describe a lot that cannot exist.
///
/// zfin now reads all of these the same way everywhere (see the
/// lifecycle block on `Lot`: `isSoldAsOf`, `hasEndedAsOf`,
/// `lotIsOpenAsOf`), so none of them makes the program disagree
/// with itself any more. They are still flagged because each one
/// almost certainly isn't what you meant: a future `close_date` is
/// usually a mistyped year, and it leaves the lot held until then.
///
/// Watch lots are skipped throughout: `Portfolio.watchSymbols` reads
/// only `symbol`, so their dates are inert.
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
if (rec.security_type == .watch) return;
const a = sink.allocator;
const name = lotName(rec);
if (rec.close_date) |cd| {
// End-of-day semantics: a close dated today is already
// closed, so only strictly-later dates are wrong.
if (ctx.today.lessThan(cd)) {
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
a,
"{s} close_date {f} is in the future - the lot is still counted as held until then",
.{ name, cd },
));
}
// Equal is a same-day round trip, which is legitimate.
if (cd.lessThan(rec.open_date)) {
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
a,
"{s} close_date {f} is before open_date {f} - the lot can never have been held",
.{ name, cd, rec.open_date },
));
}
// Stock-only: both position builders skip non-stock lots,
// so realized P&L is a stock concept. A cash or CD lot
// closed without a price is plausible - its shares ARE its
// dollar value.
if (rec.close_price == null and rec.security_type == .stock) {
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
a,
"{s} has close_date but no close_price - the sale's realized gain is recorded as 0",
.{name},
));
}
} else if (rec.close_price != null) {
// `Lot.closePriceAsOf` ignores a close price until the sale
// happens, so without a `close_date` it is never used.
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
a,
"{s} has close_price but no close_date - the lot is still held and the close price is ignored",
.{name},
));
}
if (ctx.today.lessThan(rec.open_date)) {
try sink.addOwned(.semantic, "open_date", try std.fmt.allocPrint(
a,
"{s} open_date {f} is in the future - the lot is not counted as held until then",
.{ name, rec.open_date },
));
}
// Stock-only: manual-price staleness (`commands/audit/hygiene.zig`)
// only examines stock lots, so that is the only place a future
// date suppresses anything.
if (rec.security_type == .stock) {
if (rec.price_date) |pd| {
if (ctx.today.lessThan(pd)) {
try sink.addOwned(.semantic, "price_date", try std.fmt.allocPrint(
a,
"{s} price_date {f} is in the future - the manual price counts as fresh, so it will not be flagged stale",
.{ name, pd },
));
}
}
}
}
/// How a finding names the lot. Cash lots often have no symbol, so
/// fall back to the type rather than printing an empty string - the
/// line number already pins down which record it is.
fn lotName(lot: Lot) []const u8 {
const s = lot.displaySymbol();
if (s.len > 0) return s;
return switch (lot.security_type) {
.cash => "cash lot",
.cd => "CD lot",
.option => "option lot",
.illiquid => "illiquid lot",
.stock => "stock lot",
.watch => "watch lot",
};
}
};
/// Populate each open stock lot's `split_factor` from the fetched split
@ -693,7 +1001,7 @@ pub const Portfolio = struct {
const not_yet_opened = as_of.lessThan(lot.open_date);
if (!not_yet_opened) {
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
}
}
}
@ -754,9 +1062,12 @@ pub const Portfolio = struct {
pos.shares += lot.effectiveShares();
pos.total_cost += lot.costBasis();
pos.open_lots += 1;
} else {
} else if (!as_of.lessThan(lot.open_date)) {
// Not held and already bought, so it has ended. A
// not-yet-bought lot is neither - `positionsAsOf` skips
// it the same way instead of counting it as closed.
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
}
}
@ -775,11 +1086,15 @@ pub const Portfolio = struct {
return final.toOwnedSlice(allocator);
}
/// Total cash for a single account.
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
/// Total cash held by a single account on `as_of`. Closed cash
/// lots are excluded, like every other total - this one used to
/// count them, and audit's reconciliation then reported a
/// discrepancy against a brokerage that (correctly) didn't.
pub fn cashForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
}
@ -864,12 +1179,12 @@ pub const Portfolio = struct {
return total;
}
/// Total realized P&L from all closed stock lots.
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
/// Total realized P&L from stock lots sold on or before `as_of`.
pub fn totalRealizedGainLoss(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .stock) {
if (lot.realizedGainLoss()) |pnl| total += pnl;
if (lot.realizedGainLossAsOf(as_of)) |pnl| total += pnl;
}
}
return total;
@ -1131,7 +1446,7 @@ test "lot basics" {
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
try std.testing.expect(lot.realizedGainLoss() == null);
try std.testing.expect(lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)) == null);
}
test "closed lot" {
@ -1144,8 +1459,8 @@ test "closed lot" {
.close_price = 200.0,
};
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)).?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0, Date.fromYmd(2026, 5, 8)), 0.001);
}
test "portfolio positions" {
@ -1217,15 +1532,18 @@ test "Position.displaySymbol: label orelse symbol" {
}
test "Lot.returnPct" {
const as_of = Date.fromYmd(2026, 5, 8);
// Open lot: uses current_price param
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
// Closed lot: uses close_price, ignores current_price
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150, as_of), 0.001);
// Sold lot: uses close_price, ignores current_price
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999, as_of), 0.001);
// Sale not yet happened: still priced live, close_price ignored
try std.testing.expectApproxEqAbs(@as(f64, 0.5), closed_lot.returnPct(150, Date.fromYmd(2024, 5, 31)), 0.001);
// Zero open_price: returns 0
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100, as_of), 0.001);
}
test "Portfolio totals" {
@ -1243,7 +1561,7 @@ test "Portfolio totals" {
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(Date.fromYmd(2026, 5, 8)), 0.01);
// totalCash
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
// totalIlliquid
@ -2163,3 +2481,344 @@ test "extraPriceSymbols: order is stable - watch lots first, then the watchlist
try std.testing.expectEqualStrings("QTUM", out[3]);
}
}
// ── srf_schema.semanticCheck: lot lifecycle rules ──
/// `today` for these tests. Fixed, so a verdict can't flip as the
/// calendar moves past a fixture date.
const lint_today = Date.fromYmd(2026, 1, 1);
/// Run both lint passes over `data` as portfolio.srf and return only
/// the semantic findings' descriptions, joined by newlines. Caller
/// frees.
fn lifecycleFindings(data: []const u8) ![]u8 {
const allocator = std.testing.allocator;
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
defer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = lint_today });
r.sort();
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (r.findings) |f| {
if (f.kind != .semantic) continue;
try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
}
return aw.toOwnedSlice();
}
fn expectLifecycle(data: []const u8, want: []const []const u8) !void {
const got = try lifecycleFindings(data);
defer std.testing.allocator.free(got);
var lines: usize = 0;
var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, got, "\n"), '\n');
while (it.next()) |l| {
if (l.len > 0) lines += 1;
}
for (want) |w| {
// On a miss, let `expectEqualStrings` print both sides: it always
// fails here (a substring was not found), and it reports through
// `std.testing` rather than a hand-rolled print.
if (std.mem.indexOf(u8, got, w) == null) return std.testing.expectEqualStrings(w, got);
}
if (lines != want.len) {
const joined = try std.mem.join(std.testing.allocator, "\n", want);
defer std.testing.allocator.free(joined);
return std.testing.expectEqualStrings(joined, got);
}
}
test "lifecycle: a fully valid open and closed lot are clean" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01,close_price:num:560
\\
, &.{});
}
test "lifecycle: future close_date is flagged" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14,close_price:num:300
\\
, &.{"2: VTI close_date 2062-03-14 is in the future - the lot is still counted as held until then"});
}
test "lifecycle: a close dated today is already closed, not future" {
// End-of-day semantics, matching `lotIsOpenAsOf`.
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2026-01-01,close_price:num:300
\\
, &.{});
}
test "lifecycle: close before open is flagged; a same-day round trip is not" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-06-01,open_price:num:220,close_date::2024-05-01,close_price:num:230
\\symbol::SPY,shares:num:10,open_date::2024-06-01,open_price:num:470,close_date::2024-06-01,close_price:num:471
\\
, &.{"2: VTI close_date 2024-05-01 is before open_date 2024-06-01"});
}
test "lifecycle: stock lot closed without a price loses its realized gain" {
try expectLifecycle(
\\#!srfv1
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01
\\
, &.{"2: SPY has close_date but no close_price - the sale's realized gain is recorded as 0"});
}
test "lifecycle: cash and CD lots closed without a price are fine" {
// Their shares ARE their dollar value; there is no realized P&L to lose.
try expectLifecycle(
\\#!srfv1
\\security_type::cash,shares:num:5000,open_date::2024-01-01,open_price:num:1,close_date::2025-01-01,account::Sample Brokerage
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_date::2025-01-01
\\
, &.{});
}
test "lifecycle: close_price without close_date is ignored, for every type" {
try expectLifecycle(
\\#!srfv1
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_price:num:560
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:1,close_price:num:1
\\
, &.{
"2: SPY has close_price but no close_date - the lot is still held and the close price is ignored",
"3: CD1 has close_price but no close_date - the lot is still held and the close price is ignored",
});
}
test "lifecycle: future open_date is flagged" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2062-01-15,open_price:num:220
\\
, &.{"2: VTI open_date 2062-01-15 is in the future - the lot is not counted as held until then"});
}
test "lifecycle: future price_date is flagged on stock lots only" {
// Manual-price staleness only ever examines stock lots, so a future
// price_date on a CD suppresses nothing and is left alone.
try expectLifecycle(
\\#!srfv1
\\symbol::NON40,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2062-02-27
\\symbol::NON41,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2025-12-31
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,price_date::2062-01-01
\\
, &.{"2: NON40 price_date 2062-02-27 is in the future - the manual price counts as fresh"});
}
test "lifecycle: watch lots are exempt" {
// `Portfolio.watchSymbols` reads only `symbol`; dates are inert.
try expectLifecycle(
\\#!srfv1
\\security_type::watch,symbol::NVDA,shares:num:0,open_date::2062-01-01,open_price:num:0,close_date::2063-01-01
\\
, &.{});
}
test "lifecycle: maturity_date in the future is legitimate" {
try expectLifecycle(
\\#!srfv1
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2025-06-01,open_price:num:10000,maturity_date::2027-06-01,rate:num:4.5
\\
, &.{});
}
test "lifecycle: two bad lots stay two findings, and one lot can have several" {
// Rollup identity includes `detail`, which names the symbol, so the
// same rule on different lots must not collapse into one line.
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2062-03-14,close_price:num:1
\\
, &.{
"2: VTI close_date 2062-03-14 is in the future",
"2: VTI has close_date but no close_price",
"3: SPY close_date 2062-03-14 is in the future",
});
}
test "lifecycle: a lot without a symbol is named by its type" {
try expectLifecycle(
\\#!srfv1
\\security_type::cash,shares:num:5000,open_date::2062-01-01,open_price:num:1,account::Sample Brokerage
\\
, &.{"2: cash lot open_date 2062-01-01 is in the future"});
}
// ── ParsedLot / Lot.fromParsed ──
/// `lot` as the record a hand-edited file would produce.
fn parsedFrom(lot: Lot) ParsedLot {
// SAFETY: every field is assigned by the loop below.
var p: ParsedLot = undefined;
inline for (std.meta.fields(Lot)) |f| @field(p, f.name) = @field(lot, f.name);
return p;
}
test "ParsedLot: same field names as Lot, only the type-dependent ones optional" {
const lot_fields = std.meta.fields(Lot);
const parsed_fields = std.meta.fields(ParsedLot);
try std.testing.expectEqual(lot_fields.len, parsed_fields.len);
inline for (lot_fields, parsed_fields) |lf, pf| {
try std.testing.expectEqualStrings(lf.name, pf.name);
if (comptime isTypeDependent(lf.name)) {
try std.testing.expect(pf.type == ?lf.type);
} else {
try std.testing.expect(pf.type == lf.type);
}
}
}
test "fromParsed: a complete record round-trips unchanged" {
const lot = Lot{
.symbol = "VTI",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 220,
.close_date = Date.fromYmd(2025, 1, 15),
.close_price = 260,
.note = "n",
.label = "L",
.account = "Sample IRA",
.ticker = "VTSAX",
.price = 1,
.price_date = Date.fromYmd(2025, 1, 1),
.price_ratio = 2,
.drip = true,
};
const got = Lot.fromParsed(parsedFrom(lot)).lot;
try std.testing.expect(std.meta.eql(lot, got));
}
test "fromParsed: cash may omit open_date and open_price, not shares" {
var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.epoch, .open_price = 1, .security_type = .cash });
p.open_date = null;
p.open_price = null;
const lot = Lot.fromParsed(p).lot;
try std.testing.expect(lot.open_date.eql(Date.epoch));
try std.testing.expectEqual(@as(f64, 1.0), lot.open_price);
try std.testing.expectEqual(@as(f64, 5000), lot.shares);
// The balance itself is still required.
p.shares = null;
try std.testing.expectEqualStrings("shares", Lot.fromParsed(p).missing);
}
test "fromParsed: an explicit value always wins over the fill" {
var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2026, 4, 30), .open_price = 1, .security_type = .cash });
p.open_price = null;
const lot = Lot.fromParsed(p).lot;
try std.testing.expect(lot.open_date.eql(Date.fromYmd(2026, 4, 30)));
}
test "fromParsed: watch may omit all three" {
var p = parsedFrom(.{ .symbol = "NVDA", .shares = 1, .open_date = Date.epoch, .open_price = 1, .security_type = .watch });
p.shares = null;
p.open_date = null;
p.open_price = null;
const lot = Lot.fromParsed(p).lot;
try std.testing.expectEqual(@as(f64, 0), lot.shares);
try std.testing.expectEqual(@as(f64, 0), lot.open_price);
try std.testing.expect(lot.open_date.eql(Date.epoch));
}
test "fromParsed: every other type requires all three, and names the missing one" {
const needs_all = [_]LotType{ .stock, .option, .cd, .illiquid };
inline for (needs_all) |t| {
inline for (type_dependent_fields) |field| {
var p = parsedFrom(.{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1, .security_type = t });
@field(p, field) = null;
try std.testing.expectEqualStrings(field, Lot.fromParsed(p).missing);
}
}
}
test "cashForAccount: counts only cash held on as_of" {
var lots = [_]Lot{
.{ .symbol = "CASH", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA" },
.{ .symbol = "CASH", .shares = 250, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA", .close_date = Date.fromYmd(2026, 3, 1) },
.{ .symbol = "CASH", .shares = 99, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample Roth" },
};
const pf = testPortfolio(&lots);
// Before the close, both Sample IRA lots count; after, only one.
try std.testing.expectApproxEqAbs(@as(f64, 1250), pf.cashForAccount(Date.fromYmd(2026, 2, 1), "Sample IRA"), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 1000), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA"), 0.001);
// And it agrees with the portfolio-wide total that already filtered.
try std.testing.expectApproxEqAbs(pf.totalCash(Date.fromYmd(2026, 3, 1)), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA") + pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample Roth"), 0.001);
}
// ── Lot lifecycle ──
test "lifecycle predicates: sold, matured, ended, held" {
const d = Date.fromYmd;
const bought = d(2024, 1, 1);
const plain: Lot = .{ .symbol = "X", .shares = 1, .open_date = bought, .open_price = 1 };
// Held, nothing scheduled.
try std.testing.expect(!plain.isSoldAsOf(d(2025, 1, 1)));
try std.testing.expect(!plain.hasMaturedAsOf(d(2025, 1, 1)));
try std.testing.expect(!plain.hasEndedAsOf(d(2025, 1, 1)));
try std.testing.expect(plain.lotIsOpenAsOf(d(2025, 1, 1)));
// Sold: end-of-day, so the close date itself is already sold.
var sold = plain;
sold.close_date = d(2025, 6, 1);
try std.testing.expect(!sold.isSoldAsOf(d(2025, 5, 31)));
try std.testing.expect(sold.isSoldAsOf(d(2025, 6, 1)));
try std.testing.expect(sold.hasEndedAsOf(d(2025, 6, 1)));
try std.testing.expect(!sold.hasMaturedAsOf(d(2025, 6, 1)));
try std.testing.expect(!sold.lotIsOpenAsOf(d(2025, 6, 1)));
// Matured: ended and not held, but NOT sold - this is the case
// `contributions` depends on, so an expired option recorded with a
// close afterwards is still seen as a sale.
var matured = plain;
matured.maturity_date = d(2025, 3, 1);
try std.testing.expect(matured.hasMaturedAsOf(d(2025, 3, 1)));
try std.testing.expect(matured.hasEndedAsOf(d(2025, 3, 1)));
try std.testing.expect(!matured.isSoldAsOf(d(2025, 3, 1)));
try std.testing.expect(!matured.lotIsOpenAsOf(d(2025, 3, 1)));
// Not yet bought: neither held nor ended.
try std.testing.expect(!plain.lotIsOpenAsOf(d(2023, 12, 31)));
try std.testing.expect(!plain.hasEndedAsOf(d(2023, 12, 31)));
}
test "closePriceAsOf / realizedGainLossAsOf: only once sold" {
const d = Date.fromYmd;
var lot: Lot = .{ .symbol = "X", .shares = 10, .open_date = d(2024, 1, 1), .open_price = 100, .close_price = 130 };
// A close price with no close date is not a sale.
try std.testing.expectEqual(@as(?f64, null), lot.closePriceAsOf(d(2026, 1, 1)));
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
// Nor is one whose date hasn't arrived.
lot.close_date = d(2025, 6, 1);
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2025, 5, 31)));
try std.testing.expectApproxEqAbs(@as(f64, 300), lot.realizedGainLossAsOf(d(2025, 6, 1)).?, 0.001);
// Sold but recorded without a price: nothing to realize from.
lot.close_price = null;
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
}
test "positionsForAccount: a not-yet-bought lot isn't counted as closed" {
// It used to land in `closed_lots` because "not held" was taken to
// mean "closed"; `positionsAsOf` already skipped it.
var lots = [_]Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2027, 1, 1), .open_price = 250, .account = "Sample IRA" },
};
const pf = testPortfolio(&lots);
const positions = try pf.positionsForAccount(Date.fromYmd(2026, 1, 1), std.testing.allocator, "Sample IRA");
defer std.testing.allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqual(@as(usize, 0), positions[0].closed_lots);
try std.testing.expectEqual(@as(usize, 1), positions[0].open_lots);
}

View file

@ -184,6 +184,14 @@ pub const TransferRecord = struct {
}
};
/// Schema contract for `transaction_log.srf`. See
/// `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = TransferRecord;
pub const file_label: []const u8 = "transaction_log.srf";
pub const doc_path: []const u8 = "reference/config/transaction-log-srf.md";
};
/// Parsed transaction log. `transfers` is allocator-owned; all string
/// fields on each record (including the symbol inside `dest_lot.lot`)
/// are also owned by the log's allocator.

View file

@ -5485,24 +5485,43 @@ test "dividendTtl: the chosen clock is what actually lands on disk" {
var store = cache.Store.init(io, allocator, dir_path);
const today = fmt.todayDate(io);
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
// `store.write` reads the clock itself, so a single `now` captured
// here can be a second behind the one it stamps - which flaked this
// test as `expected 516344, found 516345`. Bracket each write
// instead: the stamped expiry must be `offset` past SOME instant in
// [before, after].
const Bracket = struct {
fn expect(expires: i64, offset: i64, before_s: i64, after_s: i64) !void {
try std.testing.expect(expires >= before_s + offset);
try std.testing.expect(expires <= after_s + offset);
}
};
// Established payer -> ~14 days.
var established = quarterlySeries(6, today.addDays(-30), 91);
const long_before = std.Io.Timestamp.now(io, .real).toSeconds();
store.write(Dividend, "TESTA", established[0..], DataService.dividendTtl(&established));
const long_after = std.Io.Timestamp.now(io, .real).toSeconds();
const long_expires = readExpires(io, allocator, dir_path, "TESTA") orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(
try Bracket.expect(
long_expires,
cache.computeExpires(0, DataService.dividendTtl(&established), "TESTA"),
long_expires - now_s,
long_before,
long_after,
);
// Sparse history -> ~6 days.
var sparse = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 } });
const short_before = std.Io.Timestamp.now(io, .real).toSeconds();
store.write(Dividend, "TESTB", sparse[0..], DataService.dividendTtl(&sparse));
const short_after = std.Io.Timestamp.now(io, .real).toSeconds();
const short_expires = readExpires(io, allocator, dir_path, "TESTB") orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(
try Bracket.expect(
short_expires,
cache.computeExpires(0, DataService.dividendTtl(&sparse), "TESTB"),
short_expires - now_s,
short_before,
short_after,
);
// And the long one really is longer, so nobody can "simplify" the two

1896
src/srf_lint.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,17 @@ const srf = @import("srf");
/// `projections.srf`, `imported_values.srf`, `acknowledgments.srf`, and
/// the keybind config.
///
/// **`grep -rn srf_opts.user_edited src/` is the authoritative index of
/// hand-edited SRF files, and `src/srf_lint.zig`'s `schemas` registry
/// must cover the same set.** A new parse site here needs a matching
/// `srf_schema` on its model and an entry in that registry, or the
/// file's field names go unchecked - SRF silently discards a key that
/// names no field of the target struct, so a typo in an unregistered
/// file is invisible. (The one deliberate exception is
/// `imported_values.srf`, which is machine-generated; see the registry's
/// doc comment.) Zig cannot enforce this - there is no way to ask which
/// files reference a constant - so it is a convention, hence this note.
///
/// Accepts a string where a number was declared, because a hand-typed
/// `close_price::200.00` instead of `close_price:num:200.00` is a
/// slip, not a different intent. SRF's own doc says as much: strict

View file

@ -106,6 +106,109 @@ pub const Tab = blk: {
break :blk @Enum(u8, .exhaustive, &names, &values);
};
// ── keys.srf tab-scoped bindings ──────────────────────────────
/// Why a `keys.srf` tab-scoped binding can never fire.
pub const ScopedBindingCheck = enum {
ok,
/// `scope::` names no tab, so the binding lands in a bucket
/// nothing ever consults.
unknown_scope,
/// The tab exists, but `action::` is not one of its `Action` enum
/// tags. The dispatcher finds no match and the key does nothing -
/// and because any override for a tab replaces its defaults
/// wholesale, it does not fall back to a default binding either.
unknown_action,
};
/// The one definition of "is this tab-scoped binding real", derived
/// from `tab_modules` at comptime. `keybinds.zig` deliberately doesn't
/// know about tabs, so it can't check this while loading; the TUI
/// startup path (`warnScopedBindingProblems`) and `zfin doctor` (via
/// `keybinds.srf_schema.semanticCheck`) both ask here instead.
///
/// `scope::global` is not a tab scope; callers handle it before this.
pub fn checkScopedBinding(scope: []const u8, action: []const u8) ScopedBindingCheck {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, scope)) {
const ActionT = @field(tab_modules, field.name).Action;
return if (std.meta.stringToEnum(ActionT, action) != null) .ok else .unknown_action;
}
}
return .unknown_scope;
}
/// "global, portfolio, analysis, ..." - every valid `scope::` value.
const valid_scope_list: []const u8 = blk: {
var s: []const u8 = "global";
for (std.meta.fields(@TypeOf(tab_modules))) |f| s = s ++ ", " ++ f.name;
break :blk s;
};
/// Comma-separated `Action` tags for tab `scope`, or null for a scope
/// that is not a tab. Empty for a tab with no local actions.
fn tabActionList(scope: []const u8) ?[]const u8 {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, scope)) {
const list = comptime blk: {
var s: []const u8 = "";
for (std.meta.fields(@field(tab_modules, field.name).Action), 0..) |af, i| {
s = s ++ (if (i == 0) "" else ", ") ++ af.name;
}
break :blk s;
};
return list;
}
}
return null;
}
/// The wording for a binding that `checkScopedBinding` rejected.
/// Shared by the TUI startup warning and `zfin doctor` so the two
/// cannot describe the same mistake differently.
pub fn writeScopedBindingProblem(
w: *std.Io.Writer,
scope: []const u8,
action: []const u8,
problem: ScopedBindingCheck,
) std.Io.Writer.Error!void {
switch (problem) {
.ok => {},
.unknown_scope => try w.print(
"scope '{s}' is not a tab, so the binding for action '{s}' never fires; valid scopes: {s}",
.{ scope, action, valid_scope_list },
),
.unknown_action => {
const actions = tabActionList(scope) orelse "";
if (actions.len == 0) {
try w.print("action '{s}' does nothing: the {s} tab has no local actions", .{ action, scope });
} else {
try w.print(
"action '{s}' is not a {s} tab action, so that key does nothing there; valid: {s}",
.{ action, scope, actions },
);
}
},
}
}
/// Write one `zfin: keys.srf: ...` line per tab-scoped binding in
/// `keymap` that can never fire. Returns how many were written.
fn warnScopedBindingProblems(w: *std.Io.Writer, keymap: keybinds.KeyMap) std.Io.Writer.Error!usize {
var n: usize = 0;
for (keymap.tab_overrides) |to| {
for (to.bindings) |b| {
const problem = checkScopedBinding(to.scope, b.action_name);
if (problem == .ok) continue;
try w.writeAll("zfin: keys.srf: ");
try writeScopedBindingProblem(w, to.scope, b.action_name, problem);
try w.writeAll("\n");
n += 1;
}
}
return n;
}
/// Comptime lookup table of tab-bar display labels, indexed by
/// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed
/// from the 1-indexed registry position + the tab module's
@ -946,9 +1049,11 @@ pub const App = struct {
}
}
// Action name didn't resolve; treat as
// unbound (silent - keys.srf parsing
// already validated the action exists,
// future work).
// unbound. The loader can't validate
// tab-scoped action names, so this is
// reported at startup by
// `warnScopedBindingProblems` and by
// `zfin doctor`, not here per keypress.
return false;
}
}
@ -2574,14 +2679,18 @@ pub fn run(
defer keymap.deinit();
// Surface per-record parse warnings (unknown action, malformed
// key, etc.) to stderr. Non-fatal - the keymap is otherwise
// usable; user just sees that some lines didn't take effect.
if (keymap.warnings.len > 0) {
// key, etc.) to stderr, plus tab-scoped bindings that name no tab
// or no action of that tab - the loader can't check those because
// it doesn't know about tab modules. Non-fatal - the keymap is
// otherwise usable; user just sees that some lines didn't take
// effect.
{
var stderr_buf: [4096]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
for (keymap.warnings) |w| {
try stderr_writer.interface.print("zfin: {s}\n", .{w});
}
_ = try warnScopedBindingProblems(&stderr_writer.interface, keymap);
try stderr_writer.interface.flush();
}
@ -3381,3 +3490,116 @@ test "toggleOverlay: same-sym match is case-insensitive" {
app.toggleOverlay("vti");
try testing.expectEqualStrings("", app.overlay_symbol);
}
// ── checkScopedBinding / warnScopedBindingProblems ──
test "checkScopedBinding: real tab and action is ok" {
try testing.expectEqual(ScopedBindingCheck.ok, checkScopedBinding("options", "expand_collapse"));
try testing.expectEqual(ScopedBindingCheck.ok, checkScopedBinding("portfolio", "sort_reverse"));
}
test "checkScopedBinding: misspelled scope and misspelled action" {
try testing.expectEqual(ScopedBindingCheck.unknown_scope, checkScopedBinding("optons", "expand_collapse"));
try testing.expectEqual(ScopedBindingCheck.unknown_action, checkScopedBinding("options", "expand_colapse"));
// An action that exists, but on a different tab.
try testing.expectEqual(ScopedBindingCheck.unknown_action, checkScopedBinding("options", "sort_reverse"));
}
test "checkScopedBinding: every registered tab is a valid scope" {
// Derived from `tab_modules`, so a new tab can't be forgotten.
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
try testing.expect(checkScopedBinding(f.name, "__no_such_action__") != .unknown_scope);
}
}
test "writeScopedBindingProblem: wording lists the valid choices" {
var buf: [512]u8 = undefined;
{
var w = std.Io.Writer.fixed(&buf);
try writeScopedBindingProblem(&w, "optons", "expand_collapse", .unknown_scope);
const msg = w.buffered();
try testing.expect(std.mem.indexOf(u8, msg, "scope 'optons' is not a tab") != null);
try testing.expect(std.mem.indexOf(u8, msg, "valid scopes: global, portfolio,") != null);
try testing.expect(std.mem.indexOf(u8, msg, "options") != null);
}
{
var w = std.Io.Writer.fixed(&buf);
try writeScopedBindingProblem(&w, "options", "expand_colapse", .unknown_action);
const msg = w.buffered();
try testing.expect(std.mem.startsWith(u8, msg, "action 'expand_colapse' is not a options tab action, so that key does nothing there; valid: "));
// Every one of the tab's actions is offered - derived from the
// enum here too, not a hand-copied list.
inline for (std.meta.fields(@field(tab_modules, "options").Action)) |af| {
try testing.expect(std.mem.indexOf(u8, msg, af.name) != null);
}
}
// A tab with no local actions at all gets a message that says so,
// rather than an empty "valid:" list. Found from the registry rather
// than named, so the test can't go stale when a tab gains actions.
var checked_empty = false;
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
if (comptime std.meta.fields(@field(tab_modules, f.name).Action).len == 0) {
var w = std.Io.Writer.fixed(&buf);
try writeScopedBindingProblem(&w, f.name, "anything", .unknown_action);
try testing.expectEqualStrings(
"action 'anything' does nothing: the " ++ f.name ++ " tab has no local actions",
w.buffered(),
);
checked_empty = true;
}
}
try testing.expect(checked_empty);
}
test "warnScopedBindingProblems: reports only the bindings that can't fire" {
const km: keybinds.KeyMap = .{
.bindings = &.{},
.tab_overrides = &.{
.{ .scope = "options", .bindings = &.{
.{ .action_name = "expand_collapse", .key = .{ .codepoint = 'x' } },
.{ .action_name = "expand_colapse", .key = .{ .codepoint = 'y' } },
} },
.{ .scope = "optons", .bindings = &.{
.{ .action_name = "expand_collapse", .key = .{ .codepoint = 'z' } },
} },
},
};
var buf: [2048]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try testing.expectEqual(@as(usize, 2), try warnScopedBindingProblems(&w, km));
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "zfin: keys.srf: action 'expand_colapse'") != null);
try testing.expect(std.mem.indexOf(u8, out, "zfin: keys.srf: scope 'optons'") != null);
// The valid binding is not mentioned.
try testing.expect(std.mem.indexOf(u8, out, "'expand_collapse' is not") == null);
}
test "warnScopedBindingProblems: the built-in defaults are clean" {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try testing.expectEqual(@as(usize, 0), try warnScopedBindingProblems(&w, keybinds.defaults()));
}
test "writeScopedBindingProblem: every message fits in a lint finding" {
// `srf_lint.Finding.describe` truncates at `describe_max`, and these
// messages list the valid choices. A tab that grows enough actions
// would silently clip its list in `zfin doctor` - this fails first.
const srf_lint = @import("srf_lint.zig");
// Generous stand-ins for a user's typo.
const long_scope = "a_long_misspelled_scope_name_xx";
const long_action = "a_long_misspelled_action_name_xxxxxxxx";
var buf: [4096]u8 = undefined;
{
var w = std.Io.Writer.fixed(&buf);
try writeScopedBindingProblem(&w, long_scope, long_action, .unknown_scope);
try testing.expect(w.buffered().len <= srf_lint.Finding.describe_max);
}
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
var w = std.Io.Writer.fixed(&buf);
try writeScopedBindingProblem(&w, f.name, long_action, .unknown_action);
if (w.buffered().len > srf_lint.Finding.describe_max) {
std.debug.print("{s} tab message is {d} bytes, describe_max is {d}\n", .{ f.name, w.buffered().len, srf_lint.Finding.describe_max });
return error.MessageTooLong;
}
}
}

View file

@ -2,6 +2,11 @@ const std = @import("std");
const vaxis = @import("vaxis");
const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig");
const srf_lint = @import("../srf_lint.zig");
// Cycle-safe: `tui.zig` imports this file too, and Zig resolves the
// loop lazily. Needed only by `srf_schema.semanticCheck`, which asks
// `tui.checkScopedBinding` because only `tui.zig` knows the tab set.
const tui = @import("../tui.zig");
pub const Action = enum {
quit,
@ -427,6 +432,41 @@ const RawRecord = struct {
scope: ?[]const u8 = null,
};
/// Schema contract for `keys.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = RawRecord;
pub const file_label: []const u8 = "keys.srf";
pub const doc_path: []const u8 = "reference/config/keys-srf.md";
/// Bindings that load but can never fire. The TUI prints the same
/// problems at startup; this puts them in `zfin doctor` with a line
/// number. The tab-scoped rule and its wording live in `tui.zig`
/// (`checkScopedBinding`, `writeScopedBindingProblem`), so there is
/// one definition for both.
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
_ = ctx;
const is_global = rec.scope == null or std.mem.eql(u8, rec.scope.?, "global");
if (is_global) {
// Same test the loader uses to skip the record.
if (parseAction(rec.action) == null) {
try sink.addOwned(.semantic, "action", try std.fmt.allocPrint(
sink.allocator,
"unknown global action '{s}'; binding skipped (`zfin interactive --default-keys` lists them)",
.{rec.action},
));
}
return;
}
const scope = rec.scope.?;
const problem = tui.checkScopedBinding(scope, rec.action);
if (problem == .ok) return;
var aw: std.Io.Writer.Allocating = .init(sink.allocator);
errdefer aw.deinit();
try tui.writeScopedBindingProblem(&aw.writer, scope, rec.action, problem);
try sink.addOwned(.semantic, if (problem == .unknown_scope) "scope" else "action", try aw.toOwnedSlice());
}
};
/// Load keybindings from an SRF file. Returns null if the file doesn't exist
/// or can't be parsed. On success, the caller owns the returned KeyMap and
/// must call deinit().
@ -785,3 +825,44 @@ test "printSectionHeader writes a comment header line" {
try printSectionHeader(&aw.writer, "Tab: portfolio");
try std.testing.expectEqualStrings("\n# ── Tab: portfolio ──\n", aw.written());
}
// ── srf_schema.semanticCheck ──
fn lintKeys(data: []const u8) ![]u8 {
const allocator = std.testing.allocator;
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
defer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = @import("../Date.zig").fromYmd(2026, 1, 1) });
r.sort();
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (r.findings) |f| try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
return aw.toOwnedSlice();
}
test "srf_schema.semanticCheck: valid global and scoped bindings are clean" {
const got = try lintKeys(
\\#!srfv1
\\action::quit,key::q
\\action::expand_collapse,key::x,scope::options
\\action::refresh,key::F5,scope::global
\\
);
defer std.testing.allocator.free(got);
try std.testing.expectEqualStrings("", got);
}
test "srf_schema.semanticCheck: misspelled scope, scoped action, and global action" {
const got = try lintKeys(
\\#!srfv1
\\action::expand_collapse,key::x,scope::optons
\\action::expand_colapse,key::y,scope::options
\\action::quitt,key::Q
\\
);
defer std.testing.allocator.free(got);
try std.testing.expect(std.mem.indexOf(u8, got, "2: scope 'optons' is not a tab") != null);
try std.testing.expect(std.mem.indexOf(u8, got, "3: action 'expand_colapse' is not a options tab action") != null);
try std.testing.expect(std.mem.indexOf(u8, got, "4: unknown global action 'quitt'") != null);
}

View file

@ -1071,10 +1071,10 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}
std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
// Check if any lots are DRIP
// Check if any lots go into the DRIP summary
var has_drip = false;
for (matching.items) |lot| {
if (lot.drip) {
if (fmt.isDripSummarized(app.today, lot)) {
has_drip = true;
break;
}
@ -1091,9 +1091,9 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}) catch continue;
}
} else {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
// Has DRIP lots: show the rest individually, summarize held DRIP as ST/LT
for (matching.items) |lot| {
if (!lot.drip) {
if (!fmt.isDripSummarized(app.today, lot)) {
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
@ -1273,7 +1273,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
defer cash_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today) and matchesAccountFilter(state, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue;
}
}
@ -1302,7 +1302,8 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
});
if (state.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
// Held lots only, so the rows add up to the total.
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today)) {
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
@ -1327,7 +1328,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
});
if (state.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
if (lot.security_type == .illiquid and lot.lotIsOpenAsOf(app.today)) {
state.rows.append(app.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
@ -1867,6 +1868,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
s.unrealized_gain_loss,
watch_syms.items,
app.portfolio.watchlist_prices,
app.today,
);
} else break :blk views.PositionsWidths{};
};
@ -2028,7 +2030,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
// base-ticker price, so a ratio'd lot must go
// through `effectivePriceFor` or its Value and
// Gain/Loss come out scaled by its ratio.
const use_price = views.effectivePriceFor(s.allocations, lot);
const use_price = views.effectivePriceFor(s.allocations, lot, app.today);
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{

View file

@ -197,43 +197,17 @@ pub const default_theme = Theme{
// ── SRF serialization ────────────────────────────────────────
const field_names = [_]struct { name: []const u8, offset: usize }{
.{ .name = "bg", .offset = @offsetOf(Theme, "bg") },
.{ .name = "bg_panel", .offset = @offsetOf(Theme, "bg_panel") },
.{ .name = "bg_element", .offset = @offsetOf(Theme, "bg_element") },
.{ .name = "tab_bg", .offset = @offsetOf(Theme, "tab_bg") },
.{ .name = "tab_fg", .offset = @offsetOf(Theme, "tab_fg") },
.{ .name = "tab_active_bg", .offset = @offsetOf(Theme, "tab_active_bg") },
.{ .name = "tab_active_fg", .offset = @offsetOf(Theme, "tab_active_fg") },
.{ .name = "text", .offset = @offsetOf(Theme, "text") },
.{ .name = "text_muted", .offset = @offsetOf(Theme, "text_muted") },
.{ .name = "text_dim", .offset = @offsetOf(Theme, "text_dim") },
.{ .name = "status_bg", .offset = @offsetOf(Theme, "status_bg") },
.{ .name = "status_fg", .offset = @offsetOf(Theme, "status_fg") },
.{ .name = "input_bg", .offset = @offsetOf(Theme, "input_bg") },
.{ .name = "input_fg", .offset = @offsetOf(Theme, "input_fg") },
.{ .name = "input_hint", .offset = @offsetOf(Theme, "input_hint") },
.{ .name = "accent", .offset = @offsetOf(Theme, "accent") },
.{ .name = "positive", .offset = @offsetOf(Theme, "positive") },
.{ .name = "negative", .offset = @offsetOf(Theme, "negative") },
.{ .name = "warning", .offset = @offsetOf(Theme, "warning") },
.{ .name = "info", .offset = @offsetOf(Theme, "info") },
.{ .name = "select_bg", .offset = @offsetOf(Theme, "select_bg") },
.{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") },
.{ .name = "border", .offset = @offsetOf(Theme, "border") },
.{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") },
/// Schema contract for `theme.srf`. See `srf_lint.validateSchema`.
///
/// `Record = Theme`: the loader, `printDefaults` and this lint all take
/// the key set from `std.meta.fields(Theme)`, so a new color field is
/// loadable, printed and linted with no second list to update.
pub const srf_schema = struct {
pub const Record = Theme;
pub const file_label: []const u8 = "theme.srf";
pub const doc_path: []const u8 = "reference/config/theme-srf.md";
};
fn colorPtr(theme: *Theme, offset: usize) *Color {
const bytes: [*]u8 = @ptrCast(theme);
return @ptrCast(@alignCast(bytes + offset));
}
fn colorPtrConst(theme: *const Theme, offset: usize) *const Color {
const bytes: [*]const u8 = @ptrCast(theme);
return @ptrCast(@alignCast(bytes + offset));
}
fn formatHex(c: Color) [7]u8 {
var buf: [7]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch
@ -263,9 +237,10 @@ pub fn printDefaults(io: std.Io) !void {
try out.writeAll("#\n");
try out.writeAll("# All values are hex RGB: #rrggbb\n");
for (field_names) |f| {
const c = colorPtrConst(&default_theme, f.offset);
const hex = formatHex(c.*);
// Declaration order of `Theme`, which is also the order the file
// has always been written in.
inline for (std.meta.fields(Theme)) |f| {
const hex = formatHex(@field(default_theme, f.name));
try out.print("{s}::{s}\n", .{ f.name, hex });
}
@ -298,11 +273,10 @@ pub fn loadFromData(data: []const u8) ?Theme {
else => continue,
};
const color = parseHex(str) orelse continue;
for (field_names) |f| {
if (std.mem.eql(u8, field.key, f.name)) {
colorPtr(&theme, f.offset).* = color;
break;
}
// Unknown keys fall through untouched; `zfin doctor`
// reports them via `srf_schema`.
inline for (std.meta.fields(Theme)) |f| {
if (std.mem.eql(u8, field.key, f.name)) @field(theme, f.name) = color;
}
}
}
@ -354,3 +328,26 @@ test "default theme has valid colors" {
// Text should be bright
try std.testing.expect(t.text[0] > 0xc0);
}
test "loadFromData: every Theme field is loadable by its own name" {
// Replaces the old hand-maintained name table: a field added to
// `Theme` must load from theme.srf with no second list to update.
// Each field gets a distinct color, so a key that landed on the
// wrong field would show up here too.
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.writeAll("#!srfv1\n");
inline for (std.meta.fields(Theme), 0..) |f, i| {
try aw.writer.print("{s}::#0101{x:0>2}\n", .{ f.name, @as(u8, @intCast(i)) });
}
const t = loadFromData(aw.written()) orelse return error.TestUnexpectedResult;
inline for (std.meta.fields(Theme), 0..) |f, i| {
try std.testing.expectEqual(Color{ 0x01, 0x01, @intCast(i) }, @field(t, f.name));
}
}
test "loadFromData: unknown keys are ignored, not applied to a neighbor" {
const t = loadFromData("#!srfv1\nbg_panle::#123456\n") orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(default_theme.bg_panel, t.bg_panel);
try std.testing.expectEqual(default_theme.bg, t.bg);
}

View file

@ -101,6 +101,7 @@ pub fn computeWidths(
total_gl: f64,
watch_syms: []const []const u8,
watch_prices: ?std.StringHashMap(f64),
as_of: Date,
) PositionsWidths {
var w: PositionsWidths = .{};
@ -117,7 +118,7 @@ pub fn computeWidths(
if (lot.security_type != .stock) continue;
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
const use_price = effectivePriceFor(allocations, lot);
const use_price = effectivePriceFor(allocations, lot, as_of);
// A ratio'd lot renders its own effective price in the Price
// cell (see `hasOwnPrice`), and that price can exceed every
// allocation's raw price - an 8.6x institutional ratio puts a
@ -270,10 +271,13 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
/// is answering ONE question: has this allocation been merged?
///
/// - `close_price` short-circuits first. It is the price the lot
/// actually closed at, already in the lot's own terms, so the ratio
/// is NOT reapplied. Matches how the contributions pipeline values
/// closed lots (`effectivePrice(close_price, true)`).
/// - A sold lot's `close_price` short-circuits first
/// (`Lot.closePriceAsOf`). It is the price the lot actually closed
/// at, already in the lot's own terms, so the ratio is NOT
/// reapplied. Matches how the contributions pipeline values closed
/// lots (`effectivePrice(close_price, true)`). A `close_price` on a
/// lot whose `close_date` hasn't arrived is ignored: that lot is
/// still held, and its row says so.
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
/// carries this lot's own ratio (`positionsAsOf` groups by
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
@ -306,8 +310,8 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// only case a ratio'd alias produces - are exact: with every
/// component live off the same raw price `r`, `total_mv / norm_shares`
/// reduces to `r`.
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot, as_of: Date) f64 {
if (lot.closePriceAsOf(as_of)) |cp| return lot.effectivePrice(cp, true);
for (allocations) |a| {
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
@ -479,12 +483,13 @@ pub const Options = struct {
expired_count: usize,
allocator: std.mem.Allocator,
/// Expired option rows (matured strictly before `as_of`).
/// Ended option rows: expired or closed on or before `as_of`
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: Options) []const Option {
return self.items[0..self.expired_count];
}
/// Active option rows (not yet expired, including null-maturity).
/// Active option rows (not ended, including null-maturity).
pub fn activeItems(self: Options) []const Option {
return self.items[self.expired_count..];
}
@ -509,49 +514,56 @@ pub const Options = struct {
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
// Ended rows first, then active, each in maturity order. A
// maturity sort alone no longer yields that prefix: an option
// closed early (past `close_date`, future maturity) has ended
// but sorts among the active ones.
var expired_count: usize = 0;
for (tmp.items) |lot| {
const qty = lot.shares;
const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier;
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
if (is_expired) expired_count += 1;
const received = qty < 0;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of);
if (is_expired != want_ended) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier;
if (is_expired) expired_count += 1;
const received = qty < 0;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
var cost_buf: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct = lot.account orelse "";
var cost_buf: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
lot.displaySymbol(),
qty,
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
prem_str,
acct,
});
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
lot.displaySymbol(),
qty,
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
prem_str,
acct,
});
try list.append(allocator, .{
.lot = lot,
.premium = premium,
.received = received,
.is_expired = is_expired,
.row_style = row_style,
.premium_style = premium_style,
.columns = .{
.{ .text = text, .style = row_style },
.{ .text = prem_str, .style = premium_style },
},
.premium_col_start = OptionsLayout.premium_col_start,
});
try list.append(allocator, .{
.lot = lot,
.premium = premium,
.received = received,
.is_expired = is_expired,
.row_style = row_style,
.premium_style = premium_style,
.columns = .{
.{ .text = text, .style = row_style },
.{ .text = prem_str, .style = premium_style },
},
.premium_col_start = OptionsLayout.premium_col_start,
});
}
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
}
@ -607,12 +619,13 @@ pub const CDs = struct {
expired_count: usize,
allocator: std.mem.Allocator,
/// Matured CD rows (maturity strictly before `as_of`).
/// Ended CD rows: matured or closed on or before `as_of`
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: CDs) []const CD {
return self.items[0..self.expired_count];
}
/// Active CD rows (not yet matured, including null-maturity).
/// Active CD rows (not ended, including null-maturity).
pub fn activeItems(self: CDs) []const CD {
return self.items[self.expired_count..];
}
@ -637,39 +650,43 @@ pub const CDs = struct {
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
// Ended rows first, then active - see `Options.init`.
var expired_count: usize = 0;
for (tmp.items) |lot| {
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
if (is_expired) expired_count += 1;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of);
if (is_expired != want_ended) continue;
if (is_expired) expired_count += 1;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct = lot.account orelse "";
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
lot.displaySymbol(),
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
rate_str,
mat_str,
note_display,
acct,
});
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
lot.displaySymbol(),
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
rate_str,
mat_str,
note_display,
acct,
});
try list.append(allocator, .{
.lot = lot,
.is_expired = is_expired,
.row_style = row_style,
.text = text,
});
try list.append(allocator, .{
.lot = lot,
.is_expired = is_expired,
.row_style = row_style,
.text = text,
});
}
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
}
@ -691,6 +708,9 @@ const testing = std.testing;
const portfolio_mod = @import("../models/portfolio.zig");
const valuation = @import("../analytics/valuation.zig");
/// `as_of` for lot-pricing tests: after every fixture date, so a lot
/// with a `close_date` is sold and one without is held.
const test_as_of = Date.fromYmd(2026, 6, 1);
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
@ -941,7 +961,7 @@ test "effectivePriceFor: live price gets the lot's ratio applied" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot, test_as_of), 1e-6);
}
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
@ -958,7 +978,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
};
const eff_price = effectivePriceFor(&allocs, lot);
const eff_price = effectivePriceFor(&allocs, lot, test_as_of);
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
// The whole point: value must land on the real figure, not the
// ratio-skipped one.
@ -969,7 +989,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
@ -992,11 +1012,11 @@ test "effectivePriceFor: unmerged live price is already effective, ratio NOT rea
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
};
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot, test_as_of));
// And the lot row must reconcile with its own position row.
try testing.expectApproxEqRel(
allocs[0].market_value,
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
lot.effectiveShares() * effectivePriceFor(&allocs, lot, test_as_of),
1e-9,
);
}
@ -1019,7 +1039,7 @@ test "effectivePriceFor: unmerged manual price is also already effective" {
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 18.15,
};
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
@ -1038,7 +1058,7 @@ test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot, test_as_of), 1e-6);
}
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
@ -1053,25 +1073,46 @@ test "effectivePriceFor: close_price wins over the allocation and skips the rati
.shares = 2412.601,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 150.39,
};
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: close_price is ignored until the sale happens" {
// A `close_price` with no `close_date` - or one still in the future
// - is on a lot that's still held (`Lot.closePriceAsOf`), so its row
// is priced live like any other held lot. It used to show the close
// price while the status column said "open".
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
var lot = Lot{
.symbol = "BENCH",
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 50,
.close_price = 150.39,
};
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
lot.close_date = Date.fromYmd(2027, 1, 1);
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
// ...and takes over the day it arrives.
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, Date.fromYmd(2027, 1, 1)));
}
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
// A CUSIP lot must find its allocation under the ticker alias.
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip, test_as_of));
var aliased = by_cusip;
aliased.ticker = "BENCH";
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased, test_as_of));
}
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
@ -1144,7 +1185,7 @@ test "effectivePriceFor: lot rows sum to their merged position's market value" {
var lot_mv_total: f64 = 0;
var lot_gl_total: f64 = 0;
for (lots) |lot| {
const eff_price = effectivePriceFor(summary.allocations, lot);
const eff_price = effectivePriceFor(summary.allocations, lot, test_as_of);
lot_mv_total += lot.effectiveShares() * eff_price;
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
}
@ -1199,7 +1240,7 @@ test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position"
try testing.expectEqual(ratio, a.price_ratio);
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
const eff_price = effectivePriceFor(summary.allocations, lots[0], test_as_of);
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
@ -1275,7 +1316,7 @@ test "computeWidths: a ratio'd lot's effective price widens the Price column" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 100.0,
}};
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 10), w.price_w);
}
@ -1291,20 +1332,21 @@ test "computeWidths: the Price cell is measured only when the lot occupies it" {
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 99999.99, // "$99,999.99" = 10 cols
}};
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
// Ratio'd: the cell is occupied, so the same price now widens it.
lots[0].price_ratio = 2.0;
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
}
test "computeWidths: empty portfolio sits at the header-label floors" {
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
@ -1321,7 +1363,7 @@ test "computeWidths: a small portfolio stays at the floors (tightens, no waste)"
// at its minimum. This is the "don't waste space" half of
// fit-to-content.
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
@ -1333,7 +1375,7 @@ test "computeWidths: large crypto-scale values grow each column" {
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
// / +$5,000,000.00(14, sign + $5,000,000.00).
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 8), w.symbol_w);
try testing.expectEqual(@as(usize, 7), w.shares_w);
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
@ -1352,10 +1394,10 @@ test "computeWidths: a single lot's gain/loss can exceed the netted position" {
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
};
// Allocations only: stays at the 9-col floor.
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
// With lots: grows to fit the per-lot swing.
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
}
@ -1368,13 +1410,13 @@ test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tol
// Closed stock lot: market value / gain-loss use close_price
// (1234), not the allocation's current price (60). gl =
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 1234.0 },
// Stock lot whose symbol has no matching allocation and no
// close_price: currentPriceFor falls back to 0 (exercises the
// not-found path), contributing nothing.
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
};
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of);
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
// 999999.0 = 8-col share count was ignored).
try testing.expectEqual(@as(usize, 6), w.shares_w);
@ -1391,7 +1433,7 @@ test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
// "NOPRICE" has no map entry, so it widens Symbol but not Price
// (exercises the price-absent branch).
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp, test_as_of);
try testing.expectEqual(@as(usize, 11), w.symbol_w);
try testing.expectEqual(@as(usize, 9), w.price_w);
}
@ -1430,7 +1472,58 @@ test "computeWidths: lot columns size to effective (split-adjusted) shares" {
};
const no_allocs: []const Allocation = &.{};
const no_watch: []const []const u8 = &.{};
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null, test_as_of);
try testing.expectEqual(sharesCols(1000.0), w.shares_w);
try testing.expect(w.shares_w >= sharesCols(100.0));
}
test "Options.init: an option on its maturity day has ended" {
// End-of-day semantics, the same as the summary totals
// (`Lot.hasEndedAsOf`). This section used `maturity < as_of` and
// showed it active - and in its TOTAL - for that one day.
const as_of = Date.fromYmd(2024, 6, 21);
const lots = [_]Lot{
.{ .symbol = "AAA 2024-06-21 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 6, 21) },
};
var opts = try Options.init(as_of, testing.allocator, &lots, null);
defer opts.deinit();
try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
try testing.expectEqual(@as(usize, 0), opts.activeItems().len);
}
test "Options.init: an option closed before maturity has ended, and ended rows stay a prefix" {
// Closed early: past close_date, future maturity. It sorts among the
// active rows by maturity, so the partition - not the sort - has to
// move it into the ended prefix, or `activeItems` (and the section
// TOTAL built from it) would still include it.
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
.{ .symbol = "AAA 2024-09-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 9, 1) },
.{ .symbol = "BBB 2024-12-01 C50", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1), .close_date = Date.fromYmd(2024, 3, 1), .close_price = 0.10 },
.{ .symbol = "CCC 2025-03-01 C75", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option, .maturity_date = Date.fromYmd(2025, 3, 1) },
};
var opts = try Options.init(as_of, testing.allocator, &lots, null);
defer opts.deinit();
try testing.expectEqual(@as(usize, 1), opts.expired_count);
try testing.expectEqualStrings("BBB 2024-12-01 C50", opts.expiredItems()[0].lot.symbol);
try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
// Active rows keep their maturity order.
try testing.expectEqualStrings("AAA 2024-09-01 C100", opts.activeItems()[0].lot.symbol);
try testing.expectEqualStrings("CCC 2025-03-01 C75", opts.activeItems()[1].lot.symbol);
for (opts.items[0..opts.expired_count]) |o| try testing.expect(o.is_expired);
for (opts.items[opts.expired_count..]) |o| try testing.expect(!o.is_expired);
}
test "CDs.init: a CD closed before maturity has ended; one maturing today has too" {
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
.{ .symbol = "CD-TODAY", .shares = 5000, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 6, 1) },
.{ .symbol = "CD-BROKEN", .shares = 8000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .close_date = Date.fromYmd(2024, 4, 1) },
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 6, 1) },
};
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
defer cds.deinit();
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
}