Compare commits

..

No commits in common. "c463d94ea5c7c51b78a2417bdf905ac5f236e9cc" and "41dd29064843f1d808e27f0d9ae3b0295951272f" have entirely different histories.

35 changed files with 379 additions and 6192 deletions

View file

@ -133,13 +133,6 @@ already exist and have caught me out:
branches are load-bearing. Render it via `cli.printDirection` branches are load-bearing. Render it via `cli.printDirection`
(CLI) or `theme.directionStyle` (TUI) - never map the enum by (CLI) or `theme.directionStyle` (TUI) - never map the enum by
hand at a call site. 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 **Never open-code a per-day up/down comparison.** `close >= open` is
unconditionally true for anything priced once per day at NAV - mutual unconditionally true for anything priced once per day at NAV - mutual
@ -184,11 +177,6 @@ grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
# Per-day up/down direction - there must be exactly one rule. # Per-day up/down direction - there must be exactly one rule.
# Any hit outside models/candle.zig is a bug: # Any hit outside models/candle.zig is a bug:
grep -rn "close >= .*open\|close > .*open" src/ 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, If the search turns up an existing helper that does what you need,
@ -529,16 +517,6 @@ 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. - **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. - **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. - **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.
@ -566,7 +544,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/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/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. | | `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), `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). | | `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). |
## Code patterns and conventions ## Code patterns and conventions
@ -775,69 +753,6 @@ snapshot`, and that is the correct single-file load. The
distinction: snapshot files are immutable historical records, distinction: snapshot files are immutable historical records,
not the user's currently-edited portfolio. 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 ### Adding a new provider
1. Create `src/providers/newprovider.zig` following the existing struct pattern 1. Create `src/providers/newprovider.zig` following the existing struct pattern

View file

@ -73,7 +73,6 @@ pub fn build(b: *std.Build) void {
.{ .name = "websocket", .module = websocket_dep.module("websocket") }, .{ .name = "websocket", .module = websocket_dep.module("websocket") },
.{ .name = "build_info", .module = build_info }, .{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod }, .{ .name = "shiller_year", .module = shiller_mod },
.{ .name = "config_docs", .module = configDocsModule(b) },
}; };
// Generate Shiller annual returns data from ie_data.csv. // Generate Shiller annual returns data from ie_data.csv.
@ -165,57 +164,6 @@ 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 /// Produce the `build_info` module exposing `version` (derived from `git
/// describe`) and `build_timestamp` (committer timestamp of HEAD). /// describe`) and `build_timestamp` (committer timestamp of HEAD).
/// Consumed as `@import("build_info")` from `src/version.zig`. /// Consumed as `@import("build_info")` from `src/version.zig`.

View file

@ -1,139 +0,0 @@
/// 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,8 +102,6 @@ With no flags, `zfin audit` first prints a health report:
- **Accounts overdue for update** -- accounts past their - **Accounts overdue for update** -- accounts past their
`update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)). `update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)).
- **Brokerage files** it discovered, which it then reconciles. - **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 Portfolio hygiene
@ -119,128 +117,6 @@ With no flags, `zfin audit` first prints a health report:
"No update history found" is a nudge, not an error -- silence accounts "No update history found" is a nudge, not an error -- silence accounts
you don't actively track with `update_cadence::none`. 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) ## Auto-discovery (and your download folder)
With no `--fidelity`/`--schwab` flag, zfin looks for exports in two With no `--fidelity`/`--schwab` flag, zfin looks for exports in two

View file

@ -54,29 +54,13 @@ rather than counting toward the total:
alongside the account's cash going down. alongside the account's cash going down.
- **Reallocating -- selling one holding to buy another in the same - **Reallocating -- selling one holding to buy another in the same
account.** The sale's proceeds offset the repurchase. 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 A sale is valued at `close_price` when you record one (see
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale [`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
actually realized. If you delete the lot outright instead, or record the actually realized. If you delete the lot outright instead, there is no
close without a `close_price`, the current market price stands in -- `close_price` to read and the current market price stands in -- accurate
accurate for a recent sale, less so for one made long before the end of for a recent sale, less so for one made long before the end of the
the window. The report labels which was used: `at close` only when every window. The report labels which was used: `at close` or `at mark`.
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 Closing a position that has accumulated a lot per dividend
reinvestment retires many lots at once, so sales collapse to one line reinvestment retires many lots at once, so sales collapse to one line
@ -87,17 +71,6 @@ funded anything, and are treated accordingly. On an account marked
`cash_is_contribution::true` they also cancel that account's cash `cash_is_contribution::true` they also cancel that account's cash
credit, since the sale is not new money even though cash arrived. 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 Movement **between** accounts is a different matter -- zfin cannot tell
it from a contribution, so declare it in it from a contribution, so declare it in
[`transaction_log.srf`](../config/transaction-log-srf.md). An explicit [`transaction_log.srf`](../config/transaction-log-srf.md). An explicit

View file

@ -18,12 +18,6 @@ One binding per line:
action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE] 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`). - **Modifiers:** `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+c`).
- **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`, - **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`,
`left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`, `left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`,
@ -37,12 +31,6 @@ action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
A tab-local binding may not reuse a globally-bound key; zfin refuses A tab-local binding may not reuse a globally-bound key; zfin refuses
to start if you create that conflict. 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 ## Default global bindings
```srf ```srf

View file

@ -34,15 +34,14 @@ symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-----------------|--------|----------|---------------------------------------------------------------------------------------| |-----------------|--------|----------|---------------------------------------------------------------------------------------|
| `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. | | `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. |
| `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`. | | `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`. \*\*Optional for `cash`/`watch`; see [Minimal cash and watch lots](#minimal-cash-and-watch-lots). | | `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Not required for `cash`/`watch`. |
| `open_price` | number | Yes\*\* | Purchase price per share; `1` for `cash`, `cd` and `illiquid`, whose `shares` are dollars. \*\*Optional for `cash`/`watch`. | | `open_price` | number | Yes\*\* | Purchase price per share. \*\*Not required for `cash`/`watch`. |
| `close_date` | string | No | Sale date. Omit for an open lot. See [Closed lots](#closed-lots). | | `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). | | `close_price` | number | No | Sale price per share. See [Closed lots](#closed-lots). |
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. | | `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). | | `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). | | `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. | | `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` | 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. | | `price_date` | string | No | Date of the manual price (`YYYY-MM-DD`), for staleness display. |
@ -72,8 +71,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: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 symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
# Cash (no symbol, open_date or open_price needed) # Cash (no symbol, no open_date/open_price needed)
security_type::cash,shares:num:48000.00,account::Joint taxable security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Closed (sold) lot # 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 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
@ -90,33 +89,13 @@ 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. # 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 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: shares is the face value, open_price is 1 # CD
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 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
# Illiquid asset (net worth only): shares is the current value, open_price is 1 # Illiquid asset (net worth only)
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:1,note::Primary residence security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,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 ## Closed lots
A lot with `close_date` set is sold. Both fields are optional -- an open A lot with `close_date` set is sold. Both fields are optional -- an open
@ -131,25 +110,6 @@ two things beyond bookkeeping:
`close_price` the current market price stands in, which is close `close_price` the current market price stands in, which is close
enough for a recent sale and wrong for an old one. 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 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 such as `portfolio_closed.srf` -- the `portfolio*.srf` glob picks it up
either way, so realized gains and back-dated (`--as-of`) views stay either way, so realized gains and back-dated (`--as-of`) views stay

View file

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

View file

@ -47,7 +47,6 @@ 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). | | `from` | string | Yes | Source account name (matches an `account::` in your portfolio). |
| `to` | string | Yes | Destination account name. | | `to` | string | Yes | Destination account name. |
| `dest_lot` | string | Yes | Where it landed: `cash`, or `SYMBOL@YYYY-MM-DD` for a specific lot. | | `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` ## `type::cash` vs `type::in_kind`

View file

@ -12,7 +12,6 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
const Portfolio = @import("../models/portfolio.zig").Portfolio; const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
const fmt = @import("../format.zig"); const fmt = @import("../format.zig");
const srf_lint = @import("../srf_lint.zig");
const log = std.log.scoped(.accounts); const log = std.log.scoped(.accounts);
@ -369,94 +368,6 @@ 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. /// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced). /// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum { pub const UpdateCadence = enum {
@ -648,43 +559,39 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
continue; 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 // A zero/negative large-lot threshold is nonsensical (zero
// flags every new lot; negative is meaningless). Reject it and // flags every new lot; negative is meaningless). Reject it and
// treat the account as unset so the audit uses its default. // treat the account as unset so the audit uses its default.
const lot_threshold: ?f64 = if (problems.bad_lot_threshold) |t| blk: { const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
if (t > 0) break :blk t;
// No-op under `zig build test`: the parser's own tests feed // No-op under `zig build test`: the parser's own tests feed
// invalid thresholds (0, negative) on purpose to verify they // invalid thresholds (0, negative) on purpose to verify they
// are rejected, and the warn spam pollutes test output. // are rejected, and the warn spam pollutes test output.
if (!builtin.is_test) if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t }); log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
break :blk null; break :blk null;
} else entry.audit_large_lot_threshold; } else null;
// `harvested` is a magnitude: the annotation's parens carry the // `harvested` is a magnitude: the annotation's parens carry the
// "this is a loss" convention, so accept either sign from the // "this is a loss" convention, so accept either sign from the
// user and normalize. A non-finite value can only arrive from a // user and normalize. A non-finite value can only arrive from a
// hand-typed `inf`/`nan`; there is nothing sensible to display // hand-typed `inf`/`nan`; there is nothing sensible to display
// for it, so drop the field rather than render garbage. // for it, so drop the field rather than render garbage.
const harvested: ?f64 = if (problems.non_finite_harvested) |h| blk: { const harvested: ?f64 = if (entry.harvested) |h| blk: {
if (std.math.isFinite(h)) break :blk @abs(h);
// Silent under `zig build test`: the parser's own tests feed // Silent under `zig build test`: the parser's own tests feed
// non-finite values on purpose, and the warn spam pollutes // non-finite values on purpose, and the warn spam pollutes
// test output. // test output.
if (!builtin.is_test) if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h }); log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
break :blk null; break :blk null;
} else if (entry.harvested) |h| @abs(h) else null; } else null;
// A declared tax mix that breaks the rules falls back to the // A declared tax mix that breaks the rules falls back to the
// bare `tax_type`. Warn so a typo isn't invisible; the raw // bare `tax_type`. Warn so a typo isn't invisible; the raw
// fields are deliberately left in place so `zfin doctor` can // fields are deliberately left in place so `zfin doctor` can
// report the same problem against the file the user edits. // report the same problem against the file the user edits.
if (problems.tax_mix) |p| { if (entry.taxMixChecked().problem) |p| {
// Silent under `zig build test`: the parser's own tests feed // Silent under `zig build test`: the parser's own tests feed
// invalid mixes on purpose to verify the fallback, and the // invalid mixes on purpose to verify the fallback, and the
// warn spam pollutes test output. // warn spam pollutes test output.
@ -3411,91 +3318,3 @@ test "abbreviateSector: known long labels collapse, others pass through" {
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate")); try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
try std.testing.expectEqualStrings("", abbreviateSector("")); 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,46 +15,20 @@ const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const srf = @import("srf"); const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig"); const srf_opts = @import("../srf_opts.zig");
const srf_lint = @import("../srf_lint.zig");
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
/// Where `projections.srf` validation problems go. /// `log.warn` wrapper that no-ops under `zig build test`. Used for
/// /// validation warnings emitted while parsing user-supplied
/// The parser is the ONLY definition of these rules. Several depend on /// `projections.srf` records: the test suite intentionally feeds
/// state across the whole file - the horizon / horizon_age / event / /// invalid inputs to `validRetirementTarget` and the config-loader
/// person limits, and "at most one `retirement_target`" - so a separate /// to verify they're rejected, but the resulting stderr noise
/// lint copy would have to re-implement the parse loop, and could then /// pollutes test output. Keep using `log.warn` directly when the
/// drift from it. Instead the one loop reports through this, either way: /// warning is interesting in tests too.
/// fn warnUser(comptime fmt: []const u8, args: anytype) void {
/// - no sink (every normal load): `log.warn`, as it always has. Silent if (builtin.is_test) return;
/// under `zig build test`, whose fixtures feed invalid values on log.warn(fmt, args);
/// 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 ───────────────────────────────────────────────── // ── Life events ─────────────────────────────────────────────────
/// A resolved event ready for the simulation loop. All age-based timing /// A resolved event ready for the simulation loop. All age-based timing
@ -748,31 +722,6 @@ const SrfProjection = union(enum) {
event: SrfEvent, 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 /// Clamp on the magnitude of `spending_change` (10%/yr real, in
/// either direction). A larger drift is almost certainly a units /// either direction). A larger drift is almost certainly a units
/// typo - someone entering a fraction (0.02) where a whole percent /// typo - someone entering a fraction (0.02) where a whole percent
@ -833,18 +782,6 @@ pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8)
/// type::birthdate,date::1975-03-15 /// type::birthdate,date::1975-03-15
/// type::event,name::Social Security,start_age:num:67,amount:num:38400 /// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { 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{}; var config = UserConfig{};
const raw = data orelse return config; const raw = data orelse return config;
if (raw.len == 0) return config; if (raw.len == 0) return config;
@ -866,28 +803,17 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
// rule. A single bad value (not in {90,95,99}) is treated as // rule. A single bad value (not in {90,95,99}) is treated as
// "no annotation on this record" and doesn't poison the others. // "no annotation on this record" and doesn't poison the others.
var annotation_count: u8 = 0; 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| { const rec = field_it.to(SrfProjection, srf_opts.user_edited) catch |err| {
// Skip the record rather than losing the whole file, but // Skip the record rather than losing the whole file, but
// name the error: a dropped record reverts that setting to // name the error: a dropped record reverts that setting to
// its default without saying so. // its default without saying so. Quiet under
try rep.warn("record", "skipping malformed record ({s}); its settings revert to defaults", .{@errorName(err)}); // `zig build test`, where fixtures feed malformed records
// on purpose.
if (!builtin.is_test) {
log.warn("projections.srf: skipping malformed record: {s}", .{@errorName(err)});
}
continue; continue;
}; };
switch (rec) { switch (rec) {
@ -901,7 +827,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
if (cap >= 0) { if (cap >= 0) {
config.return_cap = cap; config.return_cap = cap;
} else { } else {
try rep.warn("return_cap", "return_cap must be >= 0 (got {d}); value ignored", .{cap}); warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
} }
} }
if (c.horizon) |h| { if (c.horizon) |h| {
@ -910,16 +836,14 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
saw_horizon = true; saw_horizon = true;
} }
if (h == 0) { if (h == 0) {
try rep.warn("horizon", "horizon must be > 0; value ignored", .{}); log.warn("projections: horizon must be > 0; ignoring record", .{});
} else if (config.horizon_count >= UserConfig.max_horizons) { } else if (config.horizon_count >= UserConfig.max_horizons) {
try rep.warn("horizon", "horizon limit reached ({d}); ignoring extra horizon (value {d})", .{ UserConfig.max_horizons, h }); log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h });
} else { } else {
config.horizons[config.horizon_count] = h; config.horizons[config.horizon_count] = h;
if (try validRetirementTarget(c.retirement_target, rep)) |conf| { if (validRetirementTarget(c.retirement_target)) |conf| {
config.horizon_targets[config.horizon_count] = conf; config.horizon_targets[config.horizon_count] = conf;
annotation_count += 1; annotation_count += 1;
if (annotation_count == 1) first_annotation_line = line;
if (annotation_count == 2) second_annotation_line = line;
} }
config.horizon_count += 1; config.horizon_count += 1;
} }
@ -936,16 +860,14 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
saw_horizon = true; saw_horizon = true;
} }
if (age == 0) { if (age == 0) {
try rep.warn("horizon_age", "horizon_age must be > 0; value ignored", .{}); log.warn("projections: horizon_age must be > 0; ignoring record", .{});
} else if (config.horizon_age_count >= UserConfig.max_horizons) { } else if (config.horizon_age_count >= UserConfig.max_horizons) {
try rep.warn("horizon_age", "horizon_age limit reached ({d}); ignoring extra horizon_age (value {d})", .{ UserConfig.max_horizons, age }); log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age });
} else { } else {
config.horizon_ages[config.horizon_age_count] = age; config.horizon_ages[config.horizon_age_count] = age;
if (try validRetirementTarget(c.retirement_target, rep)) |conf| { if (validRetirementTarget(c.retirement_target)) |conf| {
config.horizon_age_targets[config.horizon_age_count] = conf; config.horizon_age_targets[config.horizon_age_count] = conf;
annotation_count += 1; annotation_count += 1;
if (annotation_count == 1) first_annotation_line = line;
if (annotation_count == 2) second_annotation_line = line;
} }
config.horizon_age_count += 1; config.horizon_age_count += 1;
} }
@ -958,7 +880,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
if (amt >= 0) { if (amt >= 0) {
config.annual_contribution = amt; config.annual_contribution = amt;
} else { } else {
try rep.warn("annual_contribution", "annual_contribution must be >= 0 (got {d}); value ignored", .{amt}); warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt});
} }
} }
if (c.contribution_inflation_adjusted) |b| { if (c.contribution_inflation_adjusted) |b| {
@ -968,7 +890,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
if (amt >= 0) { if (amt >= 0) {
config.target_spending = amt; config.target_spending = amt;
} else { } else {
try rep.warn("target_spending", "target_spending must be >= 0 (got {d}); value ignored", .{amt}); warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
} }
} }
if (c.target_spending_inflation_adjusted) |b| { if (c.target_spending_inflation_adjusted) |b| {
@ -982,10 +904,10 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
const frac = pct / 100.0; const frac = pct / 100.0;
const cap = max_abs_spending_real_change; const cap = max_abs_spending_real_change;
if (frac > cap) { if (frac > cap) {
try rep.warn("spending_change", "spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct }); warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = cap; config.spending_real_change = cap;
} else if (frac < -cap) { } else if (frac < -cap) {
try rep.warn("spending_change", "spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct }); warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = -cap; config.spending_real_change = -cap;
} else { } else {
config.spending_real_change = frac; config.spending_real_change = frac;
@ -1001,7 +923,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
if (pct >= 0) { if (pct >= 0) {
config.survivor_spending_pct = pct; config.survivor_spending_pct = pct;
} else { } else {
try rep.warn("survivor_spending_pct", "survivor_spending_pct must be >= 0 (got {d}); value ignored", .{pct}); warnUser("projections: survivor_spending_pct must be >= 0 (got {d}); ignoring record", .{pct});
} }
} }
if (c.max_accumulation_years) |n| { if (c.max_accumulation_years) |n| {
@ -1009,12 +931,12 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
// A zero-year search ceiling is degenerate (it // A zero-year search ceiling is degenerate (it
// would only ever ask "can I retire today?"). // would only ever ask "can I retire today?").
// Almost certainly a typo; keep the default. // Almost certainly a typo; keep the default.
try rep.warn("max_accumulation_years", "max_accumulation_years must be > 0; value ignored", .{}); warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
} else if (n > max_configurable_accumulation_years) { } else if (n > max_configurable_accumulation_years) {
// Respect the intent (the user wants a large // Respect the intent (the user wants a large
// ceiling) but clamp to keep the search bounded // ceiling) but clamp to keep the search bounded
// and inside the historical data span. // and inside the historical data span.
try rep.warn("max_accumulation_years", "max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n }); warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
config.max_accumulation_years = max_configurable_accumulation_years; config.max_accumulation_years = max_configurable_accumulation_years;
} else { } else {
config.max_accumulation_years = n; config.max_accumulation_years = n;
@ -1022,7 +944,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
} }
if (c.benchmark_stock) |sym| { if (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) { if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
try rep.warn("benchmark_stock", "benchmark_stock must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_stock_buf.len, sym.len }); warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
} else { } else {
// Copy into our own buffer + length so the value // Copy into our own buffer + length so the value
// outlives both the SRF iterator's backing data // outlives both the SRF iterator's backing data
@ -1036,7 +958,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
} }
if (c.benchmark_bond) |sym| { if (c.benchmark_bond) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) { if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
try rep.warn("benchmark_bond", "benchmark_bond must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_bond_buf.len, sym.len }); warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
} else { } else {
@memcpy(config.benchmark_bond_buf[0..sym.len], sym); @memcpy(config.benchmark_bond_buf[0..sym.len], sym);
config.benchmark_bond_len = @intCast(sym.len); config.benchmark_bond_len = @intCast(sym.len);
@ -1051,15 +973,15 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
config.birthdates[idx] = b.date; config.birthdates[idx] = b.date;
if (idx >= config.birthdate_count) config.birthdate_count = idx + 1; if (idx >= config.birthdate_count) config.birthdate_count = idx + 1;
} else { } else {
try rep.warn("person", "birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons }); log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
} }
birthdate_seq += 1; birthdate_seq += 1;
}, },
.event => |e| { .event => |e| {
if (e.start_age == 0) { if (e.start_age == 0) {
try rep.warn("start_age", "event '{s}' has start_age 0; ignoring record", .{e.name}); log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name});
} else if (config.event_count >= UserConfig.max_events) { } else if (config.event_count >= UserConfig.max_events) {
try rep.warn("event", "event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name }); log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
} else { } else {
var ev = LifeEvent{ var ev = LifeEvent{
.start_age = e.start_age, .start_age = e.start_age,
@ -1084,8 +1006,7 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
// fall back to the default rule. Logged as a warning so the // fall back to the default rule. Logged as a warning so the
// user knows their override was ignored. // user knows their override was ignored.
if (annotation_count > 1) { if (annotation_count > 1) {
rep.at(second_annotation_line); warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{});
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_targets = @splat(0);
config.horizon_age_targets = @splat(0); config.horizon_age_targets = @splat(0);
} }
@ -1095,13 +1016,13 @@ fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
/// Validate a `retirement_target` SRF value. Returns the value /// Validate a `retirement_target` SRF value. Returns the value
/// unchanged if it's exactly 90, 95, or 99; returns null otherwise /// unchanged if it's exactly 90, 95, or 99; returns null otherwise
/// (reported through `rep` so the user notices the typo). Used at parse /// (logged as a warning so the user notices the typo). Used at parse
/// time; the view-layer `pickPromotedCell` trusts whatever lands in /// time; the view-layer `pickPromotedCell` trusts whatever lands in
/// `horizon_targets`. /// `horizon_targets`.
fn validRetirementTarget(raw: ?u8, rep: Reporter) error{OutOfMemory}!?u8 { fn validRetirementTarget(raw: ?u8) ?u8 {
const v = raw orelse return null; const v = raw orelse return null;
if (v == 90 or v == 95 or v == 99) return v; if (v == 90 or v == 95 or v == 99) return v;
try rep.warn("retirement_target", "retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v}); warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
return null; return null;
} }
@ -4079,13 +4000,13 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
} }
test "validRetirementTarget: 90/95/99 pass, others fail" { test "validRetirementTarget: 90/95/99 pass, others fail" {
try std.testing.expectEqual(@as(?u8, 90), try validRetirementTarget(90, .{})); try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90));
try std.testing.expectEqual(@as(?u8, 95), try validRetirementTarget(95, .{})); try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95));
try std.testing.expectEqual(@as(?u8, 99), try validRetirementTarget(99, .{})); try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(null, .{})); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(0, .{})); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(85, .{})); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85));
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(100, .{})); try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100));
} }
// ── oldestBirthdate / oldestAge tests ────────────────────────── // ── oldestBirthdate / oldestAge tests ──────────────────────────
@ -4529,231 +4450,3 @@ test "benchmarkSymbols: an override is honoured and outlives the config" {
try std.testing.expectEqualStrings("VTI", pair[0]); try std.testing.expectEqualStrings("VTI", pair[0]);
try std.testing.expectEqualStrings("BND", pair[1]); 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) { if (bp.is_cash) {
saw_cash_row = true; saw_cash_row = true;
pf_shares = portfolio.cashForAccount(as_of, portfolio_acct_name.?); pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
pf_value = pf_shares; pf_value = pf_shares;
} else { } else {
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{}; 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 // the balance is zero. Absence is strong evidence, not proof, and
// `audit` exists to surface things for a human to decide. // `audit` exists to surface things for a human to decide.
if (!saw_cash_row) { if (!saw_cash_row) {
const pf_cash = portfolio.cashForAccount(as_of, pa); const pf_cash = portfolio.cashForAccount(pa);
if (@abs(pf_cash) > cash_tolerance) { if (@abs(pf_cash) > cash_tolerance) {
portfolio_total += pf_cash; portfolio_total += pf_cash;
has_discrepancies = true; has_discrepancies = true;
@ -1719,38 +1719,3 @@ test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
defer allocator.free(sugg); defer allocator.free(sugg);
try std.testing.expectEqual(@as(usize, 0), sugg.len); 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 = .{}; var expectation: common.AccountValueExpectation = .{};
if (portfolio_acct) |pa| { if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(as_of, pa); pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices); pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
expectation = common.accountValueExpectation(portfolio, as_of, pa); expectation = common.accountValueExpectation(portfolio, as_of, pa);
} }

View file

@ -487,14 +487,6 @@ pub fn portfolioSummary(
var total_realized: f64 = 0; var total_realized: f64 = 0;
for (positions) |pos| { 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; if (pos.shares <= 0) continue;
const raw_price = prices.get(pos.symbol) orelse continue; const raw_price = prices.get(pos.symbol) orelse continue;
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false; const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
@ -502,6 +494,7 @@ pub fn portfolioSummary(
const mv = pos.marketValue(raw_price, is_manual); const mv = pos.marketValue(raw_price, is_manual);
total_value += mv; total_value += mv;
total_cost += pos.total_cost; total_cost += pos.total_cost;
total_realized += pos.realized_gain_loss;
try allocs.append(allocator, .{ try allocs.append(allocator, .{
.symbol = pos.symbol, .symbol = pos.symbol,
@ -2621,30 +2614,3 @@ 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,7 +25,6 @@ const Edgar = @import("../providers/Edgar.zig");
// require millisecond precision to avoid collisions, which a // require millisecond precision to avoid collisions, which a
// caller-provided second-resolution `now_s` couldn't give us. // caller-provided second-resolution `now_s` couldn't give us.
const Lot = @import("../models/portfolio.zig").Lot; const Lot = @import("../models/portfolio.zig").Lot;
const ParsedLot = @import("../models/portfolio.zig").ParsedLot;
const LotType = @import("../models/portfolio.zig").LotType; const LotType = @import("../models/portfolio.zig").LotType;
const Portfolio = @import("../models/portfolio.zig").Portfolio; const Portfolio = @import("../models/portfolio.zig").Portfolio;
const OptionsChain = @import("../models/option.zig").OptionsChain; const OptionsChain = @import("../models/option.zig").OptionsChain;
@ -2304,11 +2303,7 @@ pub fn deserializePortfolioDiag(
// `user_edited` coercion: see `srf_opts.zig` for why hand-edited // `user_edited` coercion: see `srf_opts.zig` for why hand-edited
// files get different options from cache files. The `catch` // files get different options from cache files. The `catch`
// below still handles genuinely unparseable values. // 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| { if (diags) |d| {
try appendParseDiag(allocator, d, data, line, @errorName(err)); try appendParseDiag(allocator, d, data, line, @errorName(err));
} else if (!builtin.is_test) { } else if (!builtin.is_test) {
@ -2320,22 +2315,6 @@ pub fn deserializePortfolioDiag(
skipped += 1; skipped += 1;
continue; 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 // Dupe owned strings before iterator.deinit() frees the backing buffer
lot.symbol = try allocator.dupe(u8, lot.symbol); lot.symbol = try allocator.dupe(u8, lot.symbol);
@ -2761,15 +2740,11 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
var s = Store.init(io, allocator, dir_path); var s = Store.init(io, allocator, dir_path);
// Bracket the write: `writeSupplement` reads the clock itself, so const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
// 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{ var incoming = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 }, .{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
}; };
s.writeSupplement(Dividend, "TEST", incoming[0..], "tiingo"); 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" }); const path = try std.fs.path.join(allocator, &.{ dir_path, "TEST", "dividends.srf" });
defer allocator.free(path); defer allocator.free(path);
@ -2785,8 +2760,8 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
const spec = DataType.dividends.ttl(); const spec = DataType.dividends.ttl();
const jitter = @divFloor(spec.seconds * @as(i64, spec.jitter_pct), 100); const jitter = @divFloor(spec.seconds * @as(i64, spec.jitter_pct), 100);
const new_expires = it.expires orelse return error.ExpiresMissing; const new_expires = it.expires orelse return error.ExpiresMissing;
try std.testing.expect(new_expires - before_s >= spec.seconds - jitter); try std.testing.expect(new_expires - now_s >= spec.seconds - jitter);
try std.testing.expect(new_expires - after_s <= spec.seconds + jitter); try std.testing.expect(new_expires - now_s <= spec.seconds + jitter);
} }
test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon)" { test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon)" {
@ -4749,62 +4724,3 @@ test "diskStats and cacheKeys agree on the symbol count" {
// still on the disk, so the top-level file still counts toward `files`. // still on the disk, so the top-level file still counts toward `files`.
try std.testing.expectEqual(@as(usize, 1), ds.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,10 +20,6 @@ const contributions = @import("../contributions.zig");
const Money = @import("../../Money.zig"); const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig"); const analysis = @import("../../analytics/analysis.zig");
const portfolio_mod = @import("../../models/portfolio.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 Date = @import("../../Date.zig");
const srf = @import("srf"); const srf = @import("srf");
const git = @import("../../git.zig"); const git = @import("../../git.zig");
@ -661,15 +657,6 @@ fn printLargeLotWarning(
var date_buf: [10]u8 = undefined; var date_buf: [10]u8 = undefined;
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; 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 "????-??-??"; 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) { const kind_label: []const u8 = switch (lot.security_type) {
.stock => "STOCK", .stock => "STOCK",
.cash => "CASH", .cash => "CASH",
@ -684,7 +671,7 @@ fn printLargeLotWarning(
.{ lot.account, kind_label, sym_for_display }, .{ lot.account, kind_label, sym_for_display },
); );
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
if (undated) try out.writeAll(" (date unknown)\n") else try out.print(" on {s}\n", .{date_str}); 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 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", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
@ -701,7 +688,7 @@ fn printLargeLotWarning(
color, color,
cli.CLR_MUTED, cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n", " transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ transfer_date, lot.value, lot.account }, .{ date_str, lot.value, lot.account },
); );
} else { } else {
try cli.printFg( try cli.printFg(
@ -709,7 +696,7 @@ fn printLargeLotWarning(
color, color,
cli.CLR_MUTED, cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n", " transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ transfer_date, lot.value, lot.account, lot.symbol, date_str }, .{ date_str, lot.value, lot.account, lot.symbol, date_str },
); );
} }
} }
@ -1244,7 +1231,7 @@ pub fn runHygieneCheck(
// (not in a git repo). Threshold is per-account: an account's // (not in a git repo). Threshold is per-account: an account's
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the // `audit_large_lot_threshold` in accounts.srf wins, otherwise the
// filter's built-in default applies. // filter's built-in default applies.
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| { if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
var found_mut = found; var found_mut = found;
defer found_mut.deinit(); defer found_mut.deinit();
@ -1278,112 +1265,9 @@ pub fn runHygieneCheck(
} }
} else |_| {} } 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", .{}); 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 ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────
test "accumulatePresent: unions account numbers across calls" { test "accumulatePresent: unions account numbers across calls" {
@ -2318,68 +2202,6 @@ 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); 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 /// Format a two-account portfolio.srf for the git-history test. Only
/// `shares` varies between revisions, which is enough for /// `shares` varies between revisions, which is enough for
/// findModifiedAccounts to flag the account. /// findModifiedAccounts to flag the account.
@ -2462,202 +2284,3 @@ test "findLastUpdateTimestamps: resolves an account changed in a non-newest comm
try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
try std.testing.expect(ira_ts < roth_ts); 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,27 +1127,14 @@ pub fn resolveAsOfOrExplain(
// ── Watchlist loading ──────────────────────────────────────── // ── 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. /// Load a watchlist SRF file containing symbol records.
/// Returns owned symbol strings. Returns null if file missing or empty. /// 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 { 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; const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null;
defer allocator.free(file_data); defer allocator.free(file_data);
const WatchEntry = struct { symbol: []const u8 };
var reader = std.Io.Reader.fixed(file_data); var reader = std.Io.Reader.fixed(file_data);
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null; var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null;
defer it.deinit(); defer it.deinit();

File diff suppressed because it is too large Load diff

View file

@ -32,15 +32,11 @@ const fmt = cli.fmt;
const Config = zfin.Config; const Config = zfin.Config;
const Lot = @import("../models/portfolio.zig").Lot; 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 Date = @import("../Date.zig");
const cache = @import("../cache/store.zig"); const cache = @import("../cache/store.zig");
const freshness = @import("../cache/freshness.zig"); const freshness = @import("../cache/freshness.zig");
const classification = @import("../models/classification.zig"); const classification = @import("../models/classification.zig");
const analysis = @import("../analytics/analysis.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 transaction_log = @import("../models/transaction_log.zig");
const imported_values = @import("../data/imported_values.zig"); const imported_values = @import("../data/imported_values.zig");
const history = @import("../history.zig"); const history = @import("../history.zig");
@ -518,66 +514,6 @@ 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. /// Join a sibling filename onto the anchor portfolio's directory.
// ── run ─────────────────────────────────────────────────────── // ── run ───────────────────────────────────────────────────────
@ -620,7 +556,6 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
for (pf.paths) |rp| { for (pf.paths) |rp| {
const c = try checkPortfolioFile(io, arena, rp.path, &all_lots); const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
try checks.append(arena, c); try checks.append(arena, c);
try appendFieldNameChecks(io, arena, &checks, portfolio_mod.srf_schema, rp.path, ctx.today);
} }
} }
@ -629,7 +564,6 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{ {
const r = checkSrfFile(io, arena, "accounts.srf", try cli.siblingPath(arena, a, "accounts.srf"), .optional, vAccounts); const r = checkSrfFile(io, arena, "accounts.srf", try cli.siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
try checks.append(arena, r); 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) { if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "accounts.srf"); const path = try cli.siblingPath(arena, a, "accounts.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| { if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -641,7 +575,6 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{ {
const r = checkSrfFile(io, arena, "metadata.srf", try cli.siblingPath(arena, a, "metadata.srf"), .optional, vMetadata); const r = checkSrfFile(io, arena, "metadata.srf", try cli.siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
try checks.append(arena, r); 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) { if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "metadata.srf"); const path = try cli.siblingPath(arena, a, "metadata.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| { if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -653,7 +586,6 @@ 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); const r = checkSrfFile(io, arena, "transaction_log.srf", try cli.siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
try checks.append(arena, r); 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) { if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "transaction_log.srf"); const path = try cli.siblingPath(arena, a, "transaction_log.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| { if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -662,12 +594,6 @@ 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 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 // imported_values.srf and the snapshots both live under
// <portfolio_dir>/history/, NOT directly beside the // <portfolio_dir>/history/, NOT directly beside the
// portfolio file. // portfolio file.
@ -683,12 +609,6 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// keys.srf / theme.srf live under $HOME/.config/zfin. // 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, .keys));
try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme)); 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 }); try sections.append(arena, .{ .title = "Files", .checks = checks.items });
} }
@ -809,28 +729,10 @@ 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" }; 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" }; return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
}; };
// Ask for diagnostics, not just the lot count. A record that fails const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
// 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" }; 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); 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 "" }; return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
} }
@ -905,15 +807,6 @@ const UserConfigKind = enum { keys, theme };
/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from /// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader. /// $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 { fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
const filename = switch (kind) { const filename = switch (kind) {
.keys => "keys.srf", .keys => "keys.srf",
@ -1724,182 +1617,3 @@ test "checkCandleFreshness: an untracked laggard does not warn" {
const c = try checkCandleFreshness(arena, &store, &tracked, &.{}, now_s); const c = try checkCandleFreshness(arena, &store, &tracked, &.{}, now_s);
try testing.expectEqual(Status.ok, c.status); 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; var prior_lookup_opt: ?PriorLotsLookup = null;
defer if (prior_lookup_opt) |*p| p.deinit(); defer if (prior_lookup_opt) |*p| p.deinit();
if (prior_portfolio_opt) |pf| { if (prior_portfolio_opt) |pf| {
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots, ctx.today); prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
} }
// ── Synthesize lots ─────────────────────────────────────── // ── Synthesize lots ───────────────────────────────────────
@ -585,7 +585,7 @@ const PriorLotsLookup = struct {
map: std.StringHashMap(*const portfolio_mod.Lot), map: std.StringHashMap(*const portfolio_mod.Lot),
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup { fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator); var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
errdefer { errdefer {
var it = map.keyIterator(); var it = map.keyIterator();
@ -594,14 +594,12 @@ const PriorLotsLookup = struct {
} }
for (lots) |*lot| { for (lots) |*lot| {
// Skip sold lots: they shouldn't anchor a re-import's // Skip closed lots: they shouldn't anchor a re-import's
// open_date for a position the brokerage shows as held. // open_date for a position the brokerage shows as held.
// (Today's import doesn't write `close_date`/`close_price` // (Today's import doesn't write `close_date`/`close_price`
// anyway, so this is also defensive against hand-edited // anyway, so this is also defensive against hand-edited
// closed lots in the file.) "Sold", not "ended": import // closed lots in the file.)
// carries `maturity_date` forward, so a brokered CD if (lot.close_date != null) continue;
// re-imported on its maturity day must still anchor.
if (lot.isSoldAsOf(today)) continue;
// Cash lots have no symbol/account-meaningful identity // Cash lots have no symbol/account-meaningful identity
// for matching across imports - skip. // for matching across imports - skip.
if (lot.security_type == .cash) continue; if (lot.security_type == .cash) continue;
@ -996,7 +994,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
.note = "imported fidelity 2024-01-15", .note = "imported fidelity 2024-01-15",
}, },
}; };
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior_lookup.deinit(); defer prior_lookup.deinit();
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup); const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
@ -1137,7 +1135,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
.note = "imported fidelity 2024-06-01", .note = "imported fidelity 2024-06-01",
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
// Export shows 120 shares now (user bought more) at avg // Export shows 120 shares now (user bought more) at avg
@ -1190,7 +1188,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
.drip = true, .drip = true,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
const positions = [_]BrokeragePosition{ const positions = [_]BrokeragePosition{
@ -1260,7 +1258,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
.security_type = .stock, .security_type = .stock,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
const positions = [_]BrokeragePosition{ const positions = [_]BrokeragePosition{
@ -1322,7 +1320,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
.security_type = .stock, .security_type = .stock,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
const positions = [_]BrokeragePosition{ const positions = [_]BrokeragePosition{
@ -1362,7 +1360,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
.security_type = .stock, .security_type = .stock,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
const positions = [_]BrokeragePosition{ const positions = [_]BrokeragePosition{
@ -1409,7 +1407,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
.security_type = .stock, .security_type = .stock,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
// Export only shows AAPL (user sold all GOOG). // Export only shows AAPL (user sold all GOOG).
@ -1442,7 +1440,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
.security_type = .cash, .security_type = .cash,
}, },
}; };
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit(); defer prior.deinit();
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null); try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);

View file

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

View file

@ -107,19 +107,6 @@ const JournalRecord = union(enum) {
note: NoteRecord, 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`; /// In-memory ack with its notes already grouped. Built by `load`;
/// consumed by callers that want "the ack and its reasoning together." /// consumed by callers that want "the ack and its reasoning together."
pub const Entry = struct { pub const Entry = struct {

View file

@ -742,19 +742,10 @@ pub const DripAggregation = struct {
/// Aggregate DRIP lots into short-term and long-term buckets. /// Aggregate DRIP lots into short-term and long-term buckets.
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year). /// 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 { pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
var result: DripAggregation = .{}; var result: DripAggregation = .{};
for (lots) |lot| { for (lots) |lot| {
if (!isDripSummarized(as_of, lot)) continue; if (!lot.drip) continue;
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT"); 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; const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
bucket.lot_count += 1; bucket.lot_count += 1;
@ -1795,31 +1786,3 @@ test "fmtSharpeOpt: asterisk and decimals" {
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true })); 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 })); 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,21 +676,6 @@ pub fn commitTimestamp(
return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed; 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`. /// Resolve a before/after commit range for diffing `repo.rel_path`.
/// ///
/// Three modes selected by `since` / `until`: /// Three modes selected by `since` / `until`:
@ -1059,19 +1044,6 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
for (sha) |c| try std.testing.expect(std.ascii.isHex(c)); 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" { test "commitAtOrBeforeDate returns null for date before repo existed" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var env = gitTestEnv(allocator); var env = gitTestEnv(allocator);

View file

@ -53,13 +53,6 @@ pub const ClassificationEntry = struct {
splits_current_through: ?Date = null, 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. /// Parsed classification data for the entire portfolio.
pub const ClassificationMap = struct { pub const ClassificationMap = struct {
entries: []ClassificationEntry, entries: []ClassificationEntry,

View file

@ -3,7 +3,6 @@ const Date = @import("../Date.zig");
const Candle = @import("candle.zig").Candle; const Candle = @import("candle.zig").Candle;
const split = @import("split.zig"); const split = @import("split.zig");
const Split = split.Split; const Split = split.Split;
const srf_lint = @import("../srf_lint.zig");
// ── Pricing model ──────────────────────────────────────────── // ── Pricing model ────────────────────────────────────────────
// //
@ -118,7 +117,7 @@ const srf_lint = @import("../srf_lint.zig");
// a merged group. // a merged group.
// //
// So: per-lot display sites MUST price through // So: per-lot display sites MUST price through
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot, as_of)`, // `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
// which resolves `close_price`, discriminates on `price_ratio`, and // which resolves `close_price`, discriminates on `price_ratio`, and
// applies the ratio via `Lot.effectivePrice`. Never read // applies the ratio via `Lot.effectivePrice`. Never read
// `Allocation.current_price` into a per-lot calculation, and never // `Allocation.current_price` into a per-lot calculation, and never
@ -367,66 +366,30 @@ pub const Lot = struct {
return self.lotIsOpenAsOf(as_of); return self.lotIsOpenAsOf(as_of);
} }
// ── Lot lifecycle: THE definition of open, sold, and ended ── /// Was the lot held at end-of-day on `as_of`?
//
// 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`) /// Used by historical snapshot backfill (`zfin snapshot --as-of`)
/// where "open" must be evaluated against the target date rather /// where "open" must be evaluated against the target date rather
/// than wall-clock today. `isOpen()` delegates to this. /// 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
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool { pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
// Not yet bought on `as_of`. // Not yet bought on `as_of`.
if (as_of.lessThan(self.open_date)) return false; if (as_of.lessThan(self.open_date)) return false;
return !self.hasEndedAsOf(as_of); // 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).
/// The price the lot was sold at, once the sale has happened. if (self.maturity_date) |mat| {
/// Null before `close_date` arrives even when `close_price` is set: if (!as_of.lessThan(mat)) return false;
/// 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 { return true;
if (!self.isSoldAsOf(as_of)) return null;
return self.close_price;
} }
/// Cost basis: RAW shares x RAW open_price. Split-invariant /// Cost basis: RAW shares x RAW open_price. Split-invariant
@ -464,13 +427,12 @@ pub const Lot = struct {
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted); return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
} }
/// Realized gain/loss once the lot is sold: shares * (close_price - /// Realized gain/loss for a closed lot: shares * (close_price - open_price).
/// open_price). Null until the sale happens, or when it was recorded /// Returns null if the lot is still open. Stays on RAW shares - a
/// without a `close_price`. Stays on RAW shares - a sold lot is a /// closed lot is a completed round-trip whose recorded open/close are
/// completed round-trip whose recorded open/close are consistent; /// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
/// `enrichSplits` leaves closed lots at `split_factor 1.0`. pub fn realizedGainLoss(self: Lot) ?f64 {
pub fn realizedGainLossAsOf(self: Lot, as_of: Date) ?f64 { const cp = self.close_price orelse return null;
const cp = self.closePriceAsOf(as_of) orelse return null;
return self.shares * (cp - self.open_price); return self.shares * (cp - self.open_price);
} }
@ -479,281 +441,11 @@ pub const Lot = struct {
return self.effectiveShares() * (current_price - self.effectiveOpenPrice()); return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
} }
/// Return on the lot: against the sale price once sold, otherwise pub fn returnPct(self: Lot, current_price: f64) f64 {
/// against `current_price`.
pub fn returnPct(self: Lot, current_price: f64, as_of: Date) f64 {
if (self.open_price == 0) return 0; if (self.open_price == 0) return 0;
const price = self.closePriceAsOf(as_of) orelse current_price; const price = if (self.close_price) |cp| cp else current_price;
return (price / self.effectiveOpenPrice()) - 1.0; 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 /// Populate each open stock lot's `split_factor` from the fetched split
@ -1001,7 +693,7 @@ pub const Portfolio = struct {
const not_yet_opened = as_of.lessThan(lot.open_date); const not_yet_opened = as_of.lessThan(lot.open_date);
if (!not_yet_opened) { if (!not_yet_opened) {
pos.closed_lots += 1; pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0; pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
} }
} }
} }
@ -1062,12 +754,9 @@ pub const Portfolio = struct {
pos.shares += lot.effectiveShares(); pos.shares += lot.effectiveShares();
pos.total_cost += lot.costBasis(); pos.total_cost += lot.costBasis();
pos.open_lots += 1; pos.open_lots += 1;
} else if (!as_of.lessThan(lot.open_date)) { } else {
// 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.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0; pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
} }
} }
@ -1086,15 +775,11 @@ pub const Portfolio = struct {
return final.toOwnedSlice(allocator); return final.toOwnedSlice(allocator);
} }
/// Total cash held by a single account on `as_of`. Closed cash /// Total cash for a single account.
/// lots are excluded, like every other total - this one used to pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
/// 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; var total: f64 = 0;
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.security_type != .cash) continue; if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const lot_acct = lot.account orelse continue; const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares; if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
} }
@ -1179,12 +864,12 @@ pub const Portfolio = struct {
return total; return total;
} }
/// Total realized P&L from stock lots sold on or before `as_of`. /// Total realized P&L from all closed stock lots.
pub fn totalRealizedGainLoss(self: Portfolio, as_of: Date) f64 { pub fn totalRealizedGainLoss(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.security_type == .stock) { if (lot.security_type == .stock) {
if (lot.realizedGainLossAsOf(as_of)) |pnl| total += pnl; if (lot.realizedGainLoss()) |pnl| total += pnl;
} }
} }
return total; return total;
@ -1446,7 +1131,7 @@ test "lot basics" {
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01); 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, 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.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
try std.testing.expect(lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)) == null); try std.testing.expect(lot.realizedGainLoss() == null);
} }
test "closed lot" { test "closed lot" {
@ -1459,8 +1144,8 @@ test "closed lot" {
.close_price = 200.0, .close_price = 200.0,
}; };
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8))); try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)).?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0, Date.fromYmd(2026, 5, 8)), 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
} }
test "portfolio positions" { test "portfolio positions" {
@ -1532,18 +1217,15 @@ test "Position.displaySymbol: label orelse symbol" {
} }
test "Lot.returnPct" { test "Lot.returnPct" {
const as_of = Date.fromYmd(2026, 5, 8);
// Open lot: uses current_price param // Open lot: uses current_price param
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; 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, as_of), 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
// Sold lot: uses close_price, ignores current_price // Closed 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 }; 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, as_of), 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 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 // Zero open_price: returns 0
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 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, as_of), 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
} }
test "Portfolio totals" { test "Portfolio totals" {
@ -1561,7 +1243,7 @@ test "Portfolio totals" {
// totalCostBasis: only open stock lots -> 10 * 150 = 1500 // 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); 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 // totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(Date.fromYmd(2026, 5, 8)), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
// totalCash // totalCash
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
// totalIlliquid // totalIlliquid
@ -2481,344 +2163,3 @@ test "extraPriceSymbols: order is stable - watch lots first, then the watchlist
try std.testing.expectEqualStrings("QTUM", out[3]); 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,14 +184,6 @@ 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 /// Parsed transaction log. `transfers` is allocator-owned; all string
/// fields on each record (including the symbol inside `dest_lot.lot`) /// fields on each record (including the symbol inside `dest_lot.lot`)
/// are also owned by the log's allocator. /// are also owned by the log's allocator.

View file

@ -5485,43 +5485,24 @@ test "dividendTtl: the chosen clock is what actually lands on disk" {
var store = cache.Store.init(io, allocator, dir_path); var store = cache.Store.init(io, allocator, dir_path);
const today = fmt.todayDate(io); 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. // Established payer -> ~14 days.
var established = quarterlySeries(6, today.addDays(-30), 91); 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)); 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; const long_expires = readExpires(io, allocator, dir_path, "TESTA") orelse return error.TestUnexpectedResult;
try Bracket.expect( try std.testing.expectEqual(
long_expires,
cache.computeExpires(0, DataService.dividendTtl(&established), "TESTA"), cache.computeExpires(0, DataService.dividendTtl(&established), "TESTA"),
long_before, long_expires - now_s,
long_after,
); );
// Sparse history -> ~6 days. // Sparse history -> ~6 days.
var sparse = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 } }); 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)); 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; const short_expires = readExpires(io, allocator, dir_path, "TESTB") orelse return error.TestUnexpectedResult;
try Bracket.expect( try std.testing.expectEqual(
short_expires,
cache.computeExpires(0, DataService.dividendTtl(&sparse), "TESTB"), cache.computeExpires(0, DataService.dividendTtl(&sparse), "TESTB"),
short_before, short_expires - now_s,
short_after,
); );
// And the long one really is longer, so nobody can "simplify" the two // And the long one really is longer, so nobody can "simplify" the two

File diff suppressed because it is too large Load diff

View file

@ -18,17 +18,6 @@ const srf = @import("srf");
/// `projections.srf`, `imported_values.srf`, `acknowledgments.srf`, and /// `projections.srf`, `imported_values.srf`, `acknowledgments.srf`, and
/// the keybind config. /// 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 /// 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 /// `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 /// slip, not a different intent. SRF's own doc says as much: strict

View file

@ -106,109 +106,6 @@ pub const Tab = blk: {
break :blk @Enum(u8, .exhaustive, &names, &values); 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 /// Comptime lookup table of tab-bar display labels, indexed by
/// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed /// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed
/// from the 1-indexed registry position + the tab module's /// from the 1-indexed registry position + the tab module's
@ -1049,11 +946,9 @@ pub const App = struct {
} }
} }
// Action name didn't resolve; treat as // Action name didn't resolve; treat as
// unbound. The loader can't validate // unbound (silent - keys.srf parsing
// tab-scoped action names, so this is // already validated the action exists,
// reported at startup by // future work).
// `warnScopedBindingProblems` and by
// `zfin doctor`, not here per keypress.
return false; return false;
} }
} }
@ -2679,18 +2574,14 @@ pub fn run(
defer keymap.deinit(); defer keymap.deinit();
// Surface per-record parse warnings (unknown action, malformed // Surface per-record parse warnings (unknown action, malformed
// key, etc.) to stderr, plus tab-scoped bindings that name no tab // key, etc.) to stderr. Non-fatal - the keymap is otherwise
// or no action of that tab - the loader can't check those because // usable; user just sees that some lines didn't take effect.
// it doesn't know about tab modules. Non-fatal - the keymap is if (keymap.warnings.len > 0) {
// otherwise usable; user just sees that some lines didn't take
// effect.
{
var stderr_buf: [4096]u8 = undefined; var stderr_buf: [4096]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
for (keymap.warnings) |w| { for (keymap.warnings) |w| {
try stderr_writer.interface.print("zfin: {s}\n", .{w}); try stderr_writer.interface.print("zfin: {s}\n", .{w});
} }
_ = try warnScopedBindingProblems(&stderr_writer.interface, keymap);
try stderr_writer.interface.flush(); try stderr_writer.interface.flush();
} }
@ -3490,116 +3381,3 @@ test "toggleOverlay: same-sym match is case-insensitive" {
app.toggleOverlay("vti"); app.toggleOverlay("vti");
try testing.expectEqualStrings("", app.overlay_symbol); 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,11 +2,6 @@ const std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const srf = @import("srf"); const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig"); 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 { pub const Action = enum {
quit, quit,
@ -432,41 +427,6 @@ const RawRecord = struct {
scope: ?[]const u8 = null, 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 /// 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 /// or can't be parsed. On success, the caller owns the returned KeyMap and
/// must call deinit(). /// must call deinit().
@ -825,44 +785,3 @@ test "printSectionHeader writes a comment header line" {
try printSectionHeader(&aw.writer, "Tab: portfolio"); try printSectionHeader(&aw.writer, "Tab: portfolio");
try std.testing.expectEqualStrings("\n# ── Tab: portfolio ──\n", aw.written()); 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); std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
// Check if any lots go into the DRIP summary // Check if any lots are DRIP
var has_drip = false; var has_drip = false;
for (matching.items) |lot| { for (matching.items) |lot| {
if (fmt.isDripSummarized(app.today, lot)) { if (lot.drip) {
has_drip = true; has_drip = true;
break; break;
} }
@ -1091,9 +1091,9 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}) catch continue; }) catch continue;
} }
} else { } else {
// Has DRIP lots: show the rest individually, summarize held DRIP as ST/LT // Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
for (matching.items) |lot| { for (matching.items) |lot| {
if (!fmt.isDripSummarized(app.today, lot)) { if (!lot.drip) {
state.rows.append(app.allocator, .{ state.rows.append(app.allocator, .{
.kind = .lot, .kind = .lot,
.symbol = lot.symbol, .symbol = lot.symbol,
@ -1273,7 +1273,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
var cash_lots: std.ArrayList(zfin.Lot) = .empty; var cash_lots: std.ArrayList(zfin.Lot) = .empty;
defer cash_lots.deinit(app.allocator); defer cash_lots.deinit(app.allocator);
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today) and matchesAccountFilter(state, lot.account)) { if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue; cash_lots.append(app.allocator, lot) catch continue;
} }
} }
@ -1302,8 +1302,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}); });
if (state.cash_expanded) { if (state.cash_expanded) {
for (pf.lots) |lot| { for (pf.lots) |lot| {
// Held lots only, so the rows add up to the total. if (lot.security_type == .cash) {
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today)) {
state.rows.append(app.allocator, .{ state.rows.append(app.allocator, .{
.kind = .cash_row, .kind = .cash_row,
.symbol = lot.account orelse "Unknown", .symbol = lot.account orelse "Unknown",
@ -1328,7 +1327,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}); });
if (state.illiquid_expanded) { if (state.illiquid_expanded) {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .illiquid and lot.lotIsOpenAsOf(app.today)) { if (lot.security_type == .illiquid) {
state.rows.append(app.allocator, .{ state.rows.append(app.allocator, .{
.kind = .illiquid_row, .kind = .illiquid_row,
.symbol = lot.symbol, .symbol = lot.symbol,
@ -1868,7 +1867,6 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
s.unrealized_gain_loss, s.unrealized_gain_loss,
watch_syms.items, watch_syms.items,
app.portfolio.watchlist_prices, app.portfolio.watchlist_prices,
app.today,
); );
} else break :blk views.PositionsWidths{}; } else break :blk views.PositionsWidths{};
}; };
@ -2030,7 +2028,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
// base-ticker price, so a ratio'd lot must go // base-ticker price, so a ratio'd lot must go
// through `effectivePriceFor` or its Value and // through `effectivePriceFor` or its Value and
// Gain/Loss come out scaled by its ratio. // Gain/Loss come out scaled by its ratio.
const use_price = views.effectivePriceFor(s.allocations, lot, app.today); const use_price = views.effectivePriceFor(s.allocations, lot);
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice()); const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
lot_positive = gl >= 0; lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{ lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{

View file

@ -197,17 +197,43 @@ pub const default_theme = Theme{
// ── SRF serialization ──────────────────────────────────────── // ── SRF serialization ────────────────────────────────────────
/// Schema contract for `theme.srf`. See `srf_lint.validateSchema`. const field_names = [_]struct { name: []const u8, offset: usize }{
/// .{ .name = "bg", .offset = @offsetOf(Theme, "bg") },
/// `Record = Theme`: the loader, `printDefaults` and this lint all take .{ .name = "bg_panel", .offset = @offsetOf(Theme, "bg_panel") },
/// the key set from `std.meta.fields(Theme)`, so a new color field is .{ .name = "bg_element", .offset = @offsetOf(Theme, "bg_element") },
/// loadable, printed and linted with no second list to update. .{ .name = "tab_bg", .offset = @offsetOf(Theme, "tab_bg") },
pub const srf_schema = struct { .{ .name = "tab_fg", .offset = @offsetOf(Theme, "tab_fg") },
pub const Record = Theme; .{ .name = "tab_active_bg", .offset = @offsetOf(Theme, "tab_active_bg") },
pub const file_label: []const u8 = "theme.srf"; .{ .name = "tab_active_fg", .offset = @offsetOf(Theme, "tab_active_fg") },
pub const doc_path: []const u8 = "reference/config/theme-srf.md"; .{ .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") },
}; };
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 { fn formatHex(c: Color) [7]u8 {
var buf: [7]u8 = undefined; 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 _ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch
@ -237,10 +263,9 @@ pub fn printDefaults(io: std.Io) !void {
try out.writeAll("#\n"); try out.writeAll("#\n");
try out.writeAll("# All values are hex RGB: #rrggbb\n"); try out.writeAll("# All values are hex RGB: #rrggbb\n");
// Declaration order of `Theme`, which is also the order the file for (field_names) |f| {
// has always been written in. const c = colorPtrConst(&default_theme, f.offset);
inline for (std.meta.fields(Theme)) |f| { const hex = formatHex(c.*);
const hex = formatHex(@field(default_theme, f.name));
try out.print("{s}::{s}\n", .{ f.name, hex }); try out.print("{s}::{s}\n", .{ f.name, hex });
} }
@ -273,10 +298,11 @@ pub fn loadFromData(data: []const u8) ?Theme {
else => continue, else => continue,
}; };
const color = parseHex(str) orelse continue; const color = parseHex(str) orelse continue;
// Unknown keys fall through untouched; `zfin doctor` for (field_names) |f| {
// reports them via `srf_schema`. if (std.mem.eql(u8, field.key, f.name)) {
inline for (std.meta.fields(Theme)) |f| { colorPtr(&theme, f.offset).* = color;
if (std.mem.eql(u8, field.key, f.name)) @field(theme, f.name) = color; break;
}
} }
} }
} }
@ -328,26 +354,3 @@ test "default theme has valid colors" {
// Text should be bright // Text should be bright
try std.testing.expect(t.text[0] > 0xc0); 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,7 +101,6 @@ pub fn computeWidths(
total_gl: f64, total_gl: f64,
watch_syms: []const []const u8, watch_syms: []const []const u8,
watch_prices: ?std.StringHashMap(f64), watch_prices: ?std.StringHashMap(f64),
as_of: Date,
) PositionsWidths { ) PositionsWidths {
var w: PositionsWidths = .{}; var w: PositionsWidths = .{};
@ -118,7 +117,7 @@ pub fn computeWidths(
if (lot.security_type != .stock) continue; if (lot.security_type != .stock) continue;
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares())); w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice())); w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
const use_price = effectivePriceFor(allocations, lot, as_of); const use_price = effectivePriceFor(allocations, lot);
// A ratio'd lot renders its own effective price in the Price // A ratio'd lot renders its own effective price in the Price
// cell (see `hasOwnPrice`), and that price can exceed every // cell (see `hasOwnPrice`), and that price can exceed every
// allocation's raw price - an 8.6x institutional ratio puts a // allocation's raw price - an 8.6x institutional ratio puts a
@ -271,13 +270,10 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it /// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
/// is answering ONE question: has this allocation been merged? /// is answering ONE question: has this allocation been merged?
/// ///
/// - A sold lot's `close_price` short-circuits first /// - `close_price` short-circuits first. It is the price the lot
/// (`Lot.closePriceAsOf`). It is the price the lot actually closed /// actually closed at, already in the lot's own terms, so the ratio
/// at, already in the lot's own terms, so the ratio is NOT /// is NOT reapplied. Matches how the contributions pipeline values
/// reapplied. Matches how the contributions pipeline values closed /// closed lots (`effectivePrice(close_price, true)`).
/// 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 /// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
/// carries this lot's own ratio (`positionsAsOf` groups by /// carries this lot's own ratio (`positionsAsOf` groups by
/// `(priceSymbol, price_ratio)` and propagates the ratio through). /// `(priceSymbol, price_ratio)` and propagates the ratio through).
@ -310,8 +306,8 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// only case a ratio'd alias produces - are exact: with every /// only case a ratio'd alias produces - are exact: with every
/// component live off the same raw price `r`, `total_mv / norm_shares` /// component live off the same raw price `r`, `total_mv / norm_shares`
/// reduces to `r`. /// reduces to `r`.
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot, as_of: Date) f64 { pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
if (lot.closePriceAsOf(as_of)) |cp| return lot.effectivePrice(cp, true); if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
for (allocations) |a| { for (allocations) |a| {
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue; if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0); return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
@ -483,13 +479,12 @@ pub const Options = struct {
expired_count: usize, expired_count: usize,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Ended option rows: expired or closed on or before `as_of` /// Expired option rows (matured strictly before `as_of`).
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: Options) []const Option { pub fn expiredItems(self: Options) []const Option {
return self.items[0..self.expired_count]; return self.items[0..self.expired_count];
} }
/// Active option rows (not ended, including null-maturity). /// Active option rows (not yet expired, including null-maturity).
pub fn activeItems(self: Options) []const Option { pub fn activeItems(self: Options) []const Option {
return self.items[self.expired_count..]; return self.items[self.expired_count..];
} }
@ -514,18 +509,12 @@ pub const Options = struct {
} }
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn); 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; var expired_count: usize = 0;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| { for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of);
if (is_expired != want_ended) continue;
const qty = lot.shares; const qty = lot.shares;
const cost_per = lot.open_price; const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier; 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; if (is_expired) expired_count += 1;
const received = qty < 0; const received = qty < 0;
@ -564,7 +553,6 @@ pub const Options = struct {
.premium_col_start = OptionsLayout.premium_col_start, .premium_col_start = OptionsLayout.premium_col_start,
}); });
} }
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator }; return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
} }
@ -619,13 +607,12 @@ pub const CDs = struct {
expired_count: usize, expired_count: usize,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Ended CD rows: matured or closed on or before `as_of` /// Matured CD rows (maturity strictly before `as_of`).
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: CDs) []const CD { pub fn expiredItems(self: CDs) []const CD {
return self.items[0..self.expired_count]; return self.items[0..self.expired_count];
} }
/// Active CD rows (not ended, including null-maturity). /// Active CD rows (not yet matured, including null-maturity).
pub fn activeItems(self: CDs) []const CD { pub fn activeItems(self: CDs) []const CD {
return self.items[self.expired_count..]; return self.items[self.expired_count..];
} }
@ -650,12 +637,9 @@ pub const CDs = struct {
} }
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn); std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
// Ended rows first, then active - see `Options.init`.
var expired_count: usize = 0; var expired_count: usize = 0;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| { for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of); const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
if (is_expired != want_ended) continue;
if (is_expired) expired_count += 1; if (is_expired) expired_count += 1;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal; const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
@ -687,7 +671,6 @@ pub const CDs = struct {
.text = text, .text = text,
}); });
} }
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator }; return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
} }
@ -708,9 +691,6 @@ const testing = std.testing;
const portfolio_mod = @import("../models/portfolio.zig"); const portfolio_mod = @import("../models/portfolio.zig");
const valuation = @import("../analytics/valuation.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" { test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
const as_of = Date.fromYmd(2024, 6, 1); const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{ const lots = [_]Lot{
@ -961,7 +941,7 @@ test "effectivePriceFor: live price gets the lot's ratio applied" {
.open_date = Date.fromYmd(2026, 2, 25), .open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208, .open_price = 461.240208,
}; };
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot, test_as_of), 1e-6); try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
} }
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" { test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
@ -978,7 +958,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
.open_date = Date.fromYmd(2026, 2, 26), .open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50, .open_price = 97.50,
}; };
const eff_price = effectivePriceFor(&allocs, lot, test_as_of); const eff_price = effectivePriceFor(&allocs, lot);
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6); try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
// The whole point: value must land on the real figure, not the // The whole point: value must land on the real figure, not the
// ratio-skipped one. // ratio-skipped one.
@ -989,7 +969,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" { test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)}; 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 }; 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, test_as_of)); try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
} }
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" { test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
@ -1012,11 +992,11 @@ test "effectivePriceFor: unmerged live price is already effective, ratio NOT rea
.open_date = Date.fromYmd(2026, 2, 26), .open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99, .open_price = 106.99,
}; };
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot, test_as_of)); try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
// And the lot row must reconcile with its own position row. // And the lot row must reconcile with its own position row.
try testing.expectApproxEqRel( try testing.expectApproxEqRel(
allocs[0].market_value, allocs[0].market_value,
lot.effectiveShares() * effectivePriceFor(&allocs, lot, test_as_of), lot.effectiveShares() * effectivePriceFor(&allocs, lot),
1e-9, 1e-9,
); );
} }
@ -1039,7 +1019,7 @@ test "effectivePriceFor: unmerged manual price is also already effective" {
.open_date = Date.fromYmd(2026, 2, 26), .open_date = Date.fromYmd(2026, 2, 26),
.open_price = 18.15, .open_price = 18.15,
}; };
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot, test_as_of)); try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
} }
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" { test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
@ -1058,7 +1038,7 @@ test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
.open_date = Date.fromYmd(2026, 2, 25), .open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208, .open_price = 461.240208,
}; };
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot, test_as_of), 1e-6); try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
} }
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" { test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
@ -1073,46 +1053,25 @@ test "effectivePriceFor: close_price wins over the allocation and skips the rati
.shares = 2412.601, .shares = 2412.601,
.open_date = Date.fromYmd(2026, 2, 26), .open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99, .open_price = 106.99,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 150.39, .close_price = 150.39,
}; };
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, test_as_of)); try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
}
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" { test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)}; 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 }; 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, test_as_of)); try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
} }
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" { test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
// A CUSIP lot must find its allocation under the ticker alias. // A CUSIP lot must find its allocation under the ticker alias.
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)}; 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 }; 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, test_as_of)); try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
var aliased = by_cusip; var aliased = by_cusip;
aliased.ticker = "BENCH"; aliased.ticker = "BENCH";
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased, test_as_of)); try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
} }
test "hasOwnPrice: only ratio'd lots occupy the Price cell" { test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
@ -1185,7 +1144,7 @@ test "effectivePriceFor: lot rows sum to their merged position's market value" {
var lot_mv_total: f64 = 0; var lot_mv_total: f64 = 0;
var lot_gl_total: f64 = 0; var lot_gl_total: f64 = 0;
for (lots) |lot| { for (lots) |lot| {
const eff_price = effectivePriceFor(summary.allocations, lot, test_as_of); const eff_price = effectivePriceFor(summary.allocations, lot);
lot_mv_total += lot.effectiveShares() * eff_price; lot_mv_total += lot.effectiveShares() * eff_price;
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice()); lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
} }
@ -1240,7 +1199,7 @@ test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position"
try testing.expectEqual(ratio, a.price_ratio); try testing.expectEqual(ratio, a.price_ratio);
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9); try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
const eff_price = effectivePriceFor(summary.allocations, lots[0], test_as_of); const eff_price = effectivePriceFor(summary.allocations, lots[0]);
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9); try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * 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. // Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
@ -1316,7 +1275,7 @@ test "computeWidths: a ratio'd lot's effective price widens the Price column" {
.open_date = Date.fromYmd(2026, 2, 25), .open_date = Date.fromYmd(2026, 2, 25),
.open_price = 100.0, .open_price = 100.0,
}}; }};
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null, test_as_of); const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
try testing.expectEqual(@as(usize, 10), w.price_w); try testing.expectEqual(@as(usize, 10), w.price_w);
} }
@ -1332,21 +1291,20 @@ test "computeWidths: the Price cell is measured only when the lot occupies it" {
.shares = 10, .shares = 10,
.open_date = Date.fromYmd(2026, 2, 25), .open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0, .open_price = 1.0,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 99999.99, // "$99,999.99" = 10 cols .close_price = 99999.99, // "$99,999.99" = 10 cols
}}; }};
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in. // 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. // $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, test_as_of).price_w); try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
// Ratio'd: the cell is occupied, so the same price now widens it. // Ratio'd: the cell is occupied, so the same price now widens it.
lots[0].price_ratio = 2.0; lots[0].price_ratio = 2.0;
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w); try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
} }
test "computeWidths: empty portfolio sits at the header-label floors" { test "computeWidths: empty portfolio sits at the header-label floors" {
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null, test_as_of); const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w); 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_shares_w), w.shares_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w); try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
@ -1363,7 +1321,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 // at its minimum. This is the "don't waste space" half of
// fit-to-content. // fit-to-content.
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)}; const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null, test_as_of); const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol" 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, 6), w.shares_w); // "Shares"
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost" try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
@ -1375,7 +1333,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) // 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). // / +$5,000,000.00(14, sign + $5,000,000.00).
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)}; const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null, test_as_of); const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
try testing.expectEqual(@as(usize, 8), w.symbol_w); try testing.expectEqual(@as(usize, 8), w.symbol_w);
try testing.expectEqual(@as(usize, 7), w.shares_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 try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
@ -1394,10 +1352,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 }, .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
}; };
// Allocations only: stays at the 9-col floor. // Allocations only: stays at the 9-col floor.
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null, test_as_of); const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w); try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
// With lots: grows to fit the per-lot swing. // With lots: grows to fit the per-lot swing.
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null, test_as_of); const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
try testing.expectEqual(@as(usize, 10), w.gainloss_w); try testing.expectEqual(@as(usize, 10), w.gainloss_w);
} }
@ -1410,13 +1368,13 @@ test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tol
// Closed stock lot: market value / gain-loss use close_price // Closed stock lot: market value / gain-loss use close_price
// (1234), not the allocation's current price (60). gl = // (1234), not the allocation's current price (60). gl =
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols. // 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_date = Date.fromYmd(2025, 1, 1), .close_price = 1234.0 }, .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
// Stock lot whose symbol has no matching allocation and no // Stock lot whose symbol has no matching allocation and no
// close_price: currentPriceFor falls back to 0 (exercises the // close_price: currentPriceFor falls back to 0 (exercises the
// not-found path), contributing nothing. // not-found path), contributing nothing.
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 }, .{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
}; };
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of); const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
// Non-stock lot skipped -> Shares stays at the 6-col floor (the // Non-stock lot skipped -> Shares stays at the 6-col floor (the
// 999999.0 = 8-col share count was ignored). // 999999.0 = 8-col share count was ignored).
try testing.expectEqual(@as(usize, 6), w.shares_w); try testing.expectEqual(@as(usize, 6), w.shares_w);
@ -1433,7 +1391,7 @@ test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
// "NOPRICE" has no map entry, so it widens Symbol but not Price // "NOPRICE" has no map entry, so it widens Symbol but not Price
// (exercises the price-absent branch). // (exercises the price-absent branch).
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" }; const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp, test_as_of); const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
try testing.expectEqual(@as(usize, 11), w.symbol_w); try testing.expectEqual(@as(usize, 11), w.symbol_w);
try testing.expectEqual(@as(usize, 9), w.price_w); try testing.expectEqual(@as(usize, 9), w.price_w);
} }
@ -1472,58 +1430,7 @@ test "computeWidths: lot columns size to effective (split-adjusted) shares" {
}; };
const no_allocs: []const Allocation = &.{}; const no_allocs: []const Allocation = &.{};
const no_watch: []const []const u8 = &.{}; const no_watch: []const []const u8 = &.{};
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null, test_as_of); const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
try testing.expectEqual(sharesCols(1000.0), w.shares_w); try testing.expectEqual(sharesCols(1000.0), w.shares_w);
try testing.expect(w.shares_w >= sharesCols(100.0)); 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);
}