Compare commits
13 commits
41dd290648
...
c463d94ea5
| Author | SHA1 | Date | |
|---|---|---|---|
| c463d94ea5 | |||
| c020f92449 | |||
| 1f7d2c596e | |||
| 8df6bd5c03 | |||
| cc13572d7c | |||
| c0dca12623 | |||
| b3239eefda | |||
| 60a42dd4ba | |||
| b5f3e8bcc8 | |||
| a2c8d478b6 | |||
| 433b83e78f | |||
| ce65b3c2f8 | |||
| 40869cb3a4 |
35 changed files with 6192 additions and 379 deletions
87
AGENTS.md
87
AGENTS.md
|
|
@ -133,6 +133,13 @@ already exist and have caught me out:
|
|||
branches are load-bearing. Render it via `cli.printDirection`
|
||||
(CLI) or `theme.directionStyle` (TUI) - never map the enum by
|
||||
hand at a call site.
|
||||
- `Lot.lotIsOpenAsOf` / `isSoldAsOf` / `hasMaturedAsOf` /
|
||||
`hasEndedAsOf` / `closePriceAsOf` / `realizedGainLossAsOf` - **the**
|
||||
answers to "is this lot held / sold / over, and at what price". They
|
||||
are different questions on purpose: `contributions` needs *sold* (an
|
||||
expired option recorded with a close is still a sale), holdings need
|
||||
*held*. There used to be three definitions of "closed" that disagreed;
|
||||
never test `close_date` or `close_price` directly outside `Lot`.
|
||||
|
||||
**Never open-code a per-day up/down comparison.** `close >= open` is
|
||||
unconditionally true for anything priced once per day at NAV - mutual
|
||||
|
|
@ -177,6 +184,11 @@ grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
|
|||
# Per-day up/down direction - there must be exactly one rule.
|
||||
# Any hit outside models/candle.zig is a bug:
|
||||
grep -rn "close >= .*open\|close > .*open" src/
|
||||
|
||||
# Lot liveness - held/sold/ended/matured are defined ONCE on `Lot`.
|
||||
# Any close_date/close_price read outside models/portfolio.zig is a bug
|
||||
# (maturity_date reads for sorting, display or identity are fine):
|
||||
grep -rn "\.close_date\|\.close_price" src/ | grep -v "models/portfolio.zig" | grep -v "test \"\|expect\|close_date = \|close_price = \|close_date:\|close_price:"
|
||||
```
|
||||
|
||||
If the search turns up an existing helper that does what you need,
|
||||
|
|
@ -517,6 +529,16 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
|||
|
||||
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional - it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
|
||||
|
||||
- **User-authored SRF schemas live with their models.** Each
|
||||
hand-edited file's model module declares `pub const srf_schema`
|
||||
(record type, reference-doc path, optional per-discriminator
|
||||
`field_rules`, optional `semanticCheck`); `src/srf_lint.zig` is the
|
||||
registry and walker, holding no rules of its own. Valid-name sets are
|
||||
derived from `std.meta.fields`, `field_rules` is comptime-exhaustive
|
||||
over the struct, and a doc-sync test ties each model to its reference
|
||||
page - so none of the three can drift from the fields. See "Adding a
|
||||
field to a user-authored SRF model".
|
||||
|
||||
- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs.
|
||||
|
||||
- **Providers are lazily initialized.** `DataService` fields like `td`, `pg`, `fh` start as `null` and are created on first use via `getProvider()`. The provider field name is derived from the type name at comptime.
|
||||
|
|
@ -544,7 +566,7 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
|||
| `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
|
||||
| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. |
|
||||
| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. |
|
||||
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `bcov.css` (kcov report styling). |
|
||||
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `gen_config_docs.zig` (extracts backticked identifiers from `docs/reference/config/*.md` for the schema doc-sync test, since `@embedFile` cannot escape `src/`), `bcov.css` (kcov report styling). |
|
||||
|
||||
## Code patterns and conventions
|
||||
|
||||
|
|
@ -753,6 +775,69 @@ snapshot`, and that is the correct single-file load. The
|
|||
distinction: snapshot files are immutable historical records,
|
||||
not the user's currently-edited portfolio.
|
||||
|
||||
### Adding a field to a user-authored SRF model
|
||||
|
||||
Every hand-edited SRF file has a schema that its own model module
|
||||
declares as `pub const srf_schema`, registered in `src/srf_lint.zig`'s
|
||||
`schemas`. The build enforces the contract, so you will be told what
|
||||
to do rather than having to remember:
|
||||
|
||||
1. **Add the field to the model struct.** Nothing else, then
|
||||
`zig build test`.
|
||||
2. If the model declares `field_rules` (only `Lot` does today), the
|
||||
build **fails** with the field name and three copy-pasteable
|
||||
options: read for every discriminator value, read only for some
|
||||
(`.only = &.{...}`), or `.derived = true`. Classify it.
|
||||
3. The doc-sync test then **fails** until the field appears in the
|
||||
model's reference page (`doc_path`), or is added to `undocumented`
|
||||
with a comment saying why it is not user-facing.
|
||||
|
||||
Both failures are the point. Rules and docs that live away from the
|
||||
fields they describe drift from them - `portfolio-srf.md` documented
|
||||
14 of `Lot`'s 22 fields before this existed, and adding a field had no
|
||||
consequence.
|
||||
|
||||
Why this matters: SRF's `fields.to(T)` **silently discards** any record
|
||||
key that names no field of `T` (`field_match` in srf.zig is write-only
|
||||
- see the module doc on `src/srf_lint.zig`). 19 of `Lot`'s 22 fields
|
||||
have defaults, so a typo'd key is indistinguishable from an omitted
|
||||
one. `zfin doctor` reports these per-file with a reference-page
|
||||
pointer; `zfin audit`'s Section 8 is the routine-use summary, silent
|
||||
when clean.
|
||||
|
||||
The suggestion matchers are deliberately conservative - case-insensitive
|
||||
equality and either-direction prefix, no edit distance - so they cannot
|
||||
produce a wrong guess. They therefore **miss mid-word substitutions**
|
||||
(`update_cadance` -> `update_cadence` gets no suggestion). The backstop
|
||||
is that `audit` prints the file's full valid-name set, derived from
|
||||
`std.meta.fields`, whenever a finding is about a field name
|
||||
(`Result.hasNameFindings`), and `doctor` points at the reference page,
|
||||
which the doc-sync test guarantees is complete. Keep both if you touch
|
||||
the renderers; they are what make the conservatism affordable.
|
||||
|
||||
`Rule.only` is a claim that the value is **silently ignored** for every
|
||||
other discriminator value, so each one must cite the gate that proves
|
||||
it. When a field's reach is ambiguous, leave it permissive - a missed
|
||||
nudge costs nothing, a false "zfin ignores this" sends someone chasing
|
||||
a bug that does not exist.
|
||||
|
||||
Rules that aren't about a single field's name go in the schema's
|
||||
`semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void`
|
||||
(the signature is comptime-checked). `ctx.today` is the real current
|
||||
day - the date rules (`Lot`'s future `close_date` / `open_date` /
|
||||
`price_date`) are nonsensical against a back-dated reference. The same
|
||||
permissive rule applies: `Lot`'s "close_date without close_price" fires
|
||||
only for stock lots because realized P&L is only computed for them, and
|
||||
each such scope limit cites its gate in a comment.
|
||||
|
||||
Rules that span records (limits, "at most one of") go in the schema's
|
||||
`fileCheck(data: []const u8, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void`
|
||||
instead, which gets the whole file and must set `sink.line` per record.
|
||||
When a parser already enforces such rules, have `fileCheck` run the
|
||||
parser with the sink rather than re-implement them - `projections.srf`
|
||||
does this: `parseWith` reports through a `Reporter` that logs normally
|
||||
and records findings under the lint, so each rule has one definition.
|
||||
|
||||
### Adding a new provider
|
||||
|
||||
1. Create `src/providers/newprovider.zig` following the existing struct pattern
|
||||
|
|
|
|||
52
build.zig
52
build.zig
|
|
@ -73,6 +73,7 @@ pub fn build(b: *std.Build) void {
|
|||
.{ .name = "websocket", .module = websocket_dep.module("websocket") },
|
||||
.{ .name = "build_info", .module = build_info },
|
||||
.{ .name = "shiller_year", .module = shiller_mod },
|
||||
.{ .name = "config_docs", .module = configDocsModule(b) },
|
||||
};
|
||||
|
||||
// Generate Shiller annual returns data from ie_data.csv.
|
||||
|
|
@ -164,6 +165,57 @@ pub fn build(b: *std.Build) void {
|
|||
}
|
||||
}
|
||||
|
||||
/// Produce the `config_docs` module: the set of backticked identifiers
|
||||
/// in each `docs/reference/config/*.md` reference page, extracted by
|
||||
/// `build/gen_config_docs.zig`.
|
||||
///
|
||||
/// Consumed by `src/srf_lint.zig`'s doc-sync test, which asserts every
|
||||
/// field of every user-authored model appears in that model's reference
|
||||
/// page. `@embedFile` cannot reach outside `src/`, hence the generator.
|
||||
///
|
||||
/// The directory is ENUMERATED rather than listed, so adding a new
|
||||
/// reference page needs no edit here. Each page is passed as a file arg
|
||||
/// so the build re-runs when one changes.
|
||||
fn configDocsModule(b: *std.Build) *std.Build.Module {
|
||||
const rel = "docs/reference/config";
|
||||
|
||||
const gen = b.addExecutable(.{
|
||||
.name = "gen_config_docs",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("build/gen_config_docs.zig"),
|
||||
.target = b.graph.host,
|
||||
}),
|
||||
});
|
||||
const run = b.addRunArtifact(gen);
|
||||
const output = run.addOutputFileArg("config_docs.zig");
|
||||
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
if (b.build_root.handle.openDir(b.graph.io, rel, .{ .iterate = true })) |dir| {
|
||||
var d = dir;
|
||||
defer d.close(b.graph.io);
|
||||
var it = d.iterate();
|
||||
while (it.next(b.graph.io) catch null) |entry| {
|
||||
if (entry.kind != .file) continue;
|
||||
if (!std.mem.endsWith(u8, entry.name, ".md")) continue;
|
||||
names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
|
||||
}
|
||||
} else |_| {}
|
||||
|
||||
// Sorted so the generated file - and therefore the build cache
|
||||
// hash - does not depend on directory-iteration order.
|
||||
std.mem.sort([]const u8, names.items, {}, struct {
|
||||
fn lessThan(_: void, a: []const u8, c: []const u8) bool {
|
||||
return std.mem.lessThan(u8, a, c);
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
for (names.items) |n| {
|
||||
run.addFileArg(b.path(b.fmt("{s}/{s}", .{ rel, n })));
|
||||
}
|
||||
|
||||
return b.createModule(.{ .root_source_file = output });
|
||||
}
|
||||
|
||||
/// Produce the `build_info` module exposing `version` (derived from `git
|
||||
/// describe`) and `build_timestamp` (committer timestamp of HEAD).
|
||||
/// Consumed as `@import("build_info")` from `src/version.zig`.
|
||||
|
|
|
|||
139
build/gen_config_docs.zig
Normal file
139
build/gen_config_docs.zig
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/// Build-time generator: extracts the set of backticked identifiers
|
||||
/// from each `docs/reference/config/*.md` reference page and emits them
|
||||
/// as a Zig source file.
|
||||
///
|
||||
/// Exists because `@embedFile` cannot escape the package path, so
|
||||
/// `src/` code has no way to read `docs/`. The alternative - reading
|
||||
/// the markdown at test time via cwd - would make the doc-sync check
|
||||
/// dependent on where the test binary was launched from, and a check
|
||||
/// that can silently skip is a check that rots.
|
||||
///
|
||||
/// Only the NAME SET is extracted, never the prose. That keeps the
|
||||
/// output free of string-escaping concerns and small (a few hundred
|
||||
/// identifiers), and it is exactly what the consumer needs:
|
||||
/// `srf_lint`'s doc-sync test asserts every field of each model appears
|
||||
/// in that model's reference page.
|
||||
///
|
||||
/// An "identifier" here is a backtick-delimited token matching
|
||||
/// `[a-z][a-z0-9_]*` - the shape of every SRF field name. Prose words
|
||||
/// in backticks (`true`, `null`, `stock`) come along harmlessly; the
|
||||
/// test only ever asks whether a known field name is PRESENT, never
|
||||
/// whether an extracted name is a real field.
|
||||
const std = @import("std");
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
const allocator = init.arena.allocator();
|
||||
const io = init.io;
|
||||
|
||||
const args = try init.minimal.args.toSlice(allocator);
|
||||
if (args.len < 3) {
|
||||
var stderr_buf: [160]u8 = undefined;
|
||||
var stderr = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try stderr.interface.writeAll("Usage: gen_config_docs <output.zig> <doc.md>...\n");
|
||||
try stderr.interface.flush();
|
||||
std.process.exit(1);
|
||||
}
|
||||
|
||||
const out_file = try std.Io.Dir.cwd().createFile(io, args[1], .{});
|
||||
defer out_file.close(io);
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var file_writer = out_file.writer(io, &out_buf);
|
||||
const w = &file_writer.interface;
|
||||
|
||||
try w.writeAll(
|
||||
\\// Auto-generated from docs/reference/config/*.md - do not edit.
|
||||
\\// Regenerate: zig build (runs build/gen_config_docs.zig)
|
||||
\\
|
||||
\\/// One reference page's backticked identifiers.
|
||||
\\pub const Doc = struct {
|
||||
\\ /// Basename, e.g. "portfolio-srf.md".
|
||||
\\ file: []const u8,
|
||||
\\ names: []const []const u8,
|
||||
\\};
|
||||
\\
|
||||
\\pub const docs = [_]Doc{
|
||||
\\
|
||||
);
|
||||
|
||||
for (args[2..]) |path| {
|
||||
const body = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024));
|
||||
const base = std.fs.path.basename(path);
|
||||
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
try collectIdents(allocator, body, &names);
|
||||
|
||||
try w.print(" .{{ .file = \"{s}\", .names = &.{{", .{base});
|
||||
for (names.items, 0..) |n, i| {
|
||||
if (i % 6 == 0) try w.writeAll("\n ");
|
||||
try w.print("\"{s}\", ", .{n});
|
||||
}
|
||||
try w.writeAll("\n } },\n");
|
||||
}
|
||||
|
||||
try w.writeAll(
|
||||
\\};
|
||||
\\
|
||||
\\/// The `Doc` for `file`, or null when that page was not built in.
|
||||
\\pub fn find(file: []const u8) ?Doc {
|
||||
\\ for (docs) |d| {
|
||||
\\ if (std.mem.eql(u8, d.file, file)) return d;
|
||||
\\ }
|
||||
\\ return null;
|
||||
\\}
|
||||
\\
|
||||
\\const std = @import("std");
|
||||
\\
|
||||
);
|
||||
try w.flush();
|
||||
}
|
||||
|
||||
/// Append every deduplicated backticked `[a-z][a-z0-9_]*` token in
|
||||
/// `body` to `out`. Slices borrow from `body`.
|
||||
fn collectIdents(
|
||||
allocator: std.mem.Allocator,
|
||||
body: []const u8,
|
||||
out: *std.ArrayList([]const u8),
|
||||
) !void {
|
||||
var i: usize = 0;
|
||||
while (i < body.len) {
|
||||
if (body[i] != '`') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip fenced code blocks wholesale - a ```zig sample can
|
||||
// mention a field in a context the reference table does not,
|
||||
// and counting it would let a field be "documented" by an
|
||||
// example alone.
|
||||
if (std.mem.startsWith(u8, body[i..], "```")) {
|
||||
const rest = body[i + 3 ..];
|
||||
const close = std.mem.indexOf(u8, rest, "```") orelse break;
|
||||
i += 3 + close + 3;
|
||||
continue;
|
||||
}
|
||||
const rest = body[i + 1 ..];
|
||||
const close = std.mem.indexOfScalar(u8, rest, '`') orelse break;
|
||||
const raw = rest[0..close];
|
||||
i += 1 + close + 1;
|
||||
// The docs name a field both bare (`symbol`, in reference
|
||||
// tables) and on-wire (`symbol::`, `shares:num:100`, in prose
|
||||
// and examples). Cut at the first separator so both spellings
|
||||
// count as documenting the field.
|
||||
const tok = raw[0 .. std.mem.indexOfScalar(u8, raw, ':') orelse raw.len];
|
||||
if (!isIdent(tok)) continue;
|
||||
for (out.items) |seen| {
|
||||
if (std.mem.eql(u8, seen, tok)) break;
|
||||
} else {
|
||||
try out.append(allocator, tok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn isIdent(tok: []const u8) bool {
|
||||
if (tok.len == 0) return false;
|
||||
if (tok[0] < 'a' or tok[0] > 'z') return false;
|
||||
for (tok) |c| {
|
||||
const ok = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or c == '_';
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -102,6 +102,8 @@ With no flags, `zfin audit` first prints a health report:
|
|||
- **Accounts overdue for update** -- accounts past their
|
||||
`update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)).
|
||||
- **Brokerage files** it discovered, which it then reconciles.
|
||||
- **Config file problems** -- field-name typos in your `.srf` files, impossible
|
||||
lot dates, and values zfin had to ignore or adjust.
|
||||
|
||||
```
|
||||
Portfolio hygiene
|
||||
|
|
@ -117,6 +119,128 @@ With no flags, `zfin audit` first prints a health report:
|
|||
"No update history found" is a nudge, not an error -- silence accounts
|
||||
you don't actively track with `update_cadence::none`.
|
||||
|
||||
### Config file problems
|
||||
|
||||
Your `.srf` files can be wrong in ways that don't stop them loading.
|
||||
zfin reads what it understands and quietly ignores or works around the
|
||||
rest, so a mistake can change your numbers with no error at all.
|
||||
`zfin audit` checks `portfolio*.srf`, `accounts.srf`, `metadata.srf`,
|
||||
`transaction_log.srf`, `projections.srf`, and `watchlist.srf` for three
|
||||
kinds of problem, and prints nothing when there aren't any:
|
||||
|
||||
```
|
||||
Config file problems
|
||||
portfolio.srf
|
||||
line 3: unrecognized field 'price_dat' - did you mean 'price_date'?
|
||||
line 4: BND close_date 2062-03-14 is in the future - the lot is still counted as held until then
|
||||
line 5: AAPL has close_date but no close_price - the sale's realized gain is recorded as 0
|
||||
valid fields:
|
||||
symbol shares open_date open_price close_date close_price note
|
||||
label account security_type maturity_date rate drip ticker price
|
||||
price_date price_ratio split_factor underlying strike multiplier
|
||||
option_type
|
||||
accounts.srf
|
||||
line 2: account 'Sample Brokerage': audit_large_lot_threshold must be > 0 (got 0); ignored, so the audit uses its default
|
||||
line 4: account 'Sample Roth': tax_mix_* must not name the account's own tax_type; the tax mix is ignored and the account counts wholly as roth
|
||||
```
|
||||
|
||||
Each finding names the line of the record it came from, and the date
|
||||
and value findings also say what zfin is doing instead of what you
|
||||
wrote.
|
||||
|
||||
#### 1. Field names (every file)
|
||||
|
||||
SRF matches record keys against field names exactly. A key that matches
|
||||
nothing is **discarded without complaint** -- a misspelled `price_dat::`
|
||||
is indistinguishable from having left the price date out. Most fields
|
||||
have defaults, so most typos change behavior with no output at all.
|
||||
|
||||
| Report | Meaning |
|
||||
|-----------------------------|----------------------------------------------------------------------------------------|
|
||||
| `did you mean '...'?` | A dropped or doubled character. The suggested name is a real field. |
|
||||
| `differs only by case` | `Account` is not `account`. Matching is case-sensitive, so this silently does nothing. |
|
||||
| `ignored for security_type` | A real field that this kind of lot never reads -- e.g. `rate` outside a `cd`. |
|
||||
| `appears twice` | SRF keeps the **first** occurrence. Editing the second one has no effect. |
|
||||
| `derived by zfin` | zfin computes this field; a hand-written value is overwritten. Delete it. |
|
||||
| `unrecognized field` | No match and no close relative. Check it against the `valid fields` list. |
|
||||
|
||||
The suggestions are deliberately cautious -- they only fire when a real
|
||||
field name is a prefix of what you typed, or differs from it only by
|
||||
case -- so a suggestion is never a guess. That means a typo in the
|
||||
*middle* of a name (`update_cadance`) gets no suggestion, which is why
|
||||
the **`valid fields` list** is printed whenever a field name is in
|
||||
question. It comes straight from the code, so unlike any document it
|
||||
cannot be out of date.
|
||||
|
||||
#### 2. Lot dates (`portfolio*.srf`)
|
||||
|
||||
These fields parse fine but describe a lot that can't exist. Each one
|
||||
changes what zfin counts -- a future `close_date` keeps the lot held
|
||||
until that day -- and each is almost certainly not what you meant.
|
||||
|
||||
| Report | What zfin does with it |
|
||||
|--------------------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `close_date ... is in the future` | Counts the lot as held until that date. Usually a mistyped year. |
|
||||
| `close_date ... is before open_date` | The lot is never counted as held on any date. |
|
||||
| `has close_date but no close_price` | Stock lots only. The sale's realized gain is recorded as 0. |
|
||||
| `has close_price but no close_date` | The lot stays held, and the close price is ignored. |
|
||||
| `open_date ... is in the future` | The lot is left out of your positions until that date. |
|
||||
| `price_date ... is in the future` | Stock lots only. The manual price counts as fresh, so it is never flagged as stale. |
|
||||
|
||||
A close dated today is already closed, and a same-day buy-and-sell
|
||||
(`close_date` equal to `open_date`) is fine. A future `maturity_date` is
|
||||
normal -- that is the field for a CD or option that ends on a known
|
||||
date. Watch lots are exempt, since zfin reads only their `symbol`. See
|
||||
[Closed lots](../reference/config/portfolio-srf.md#closed-lots).
|
||||
|
||||
#### 3. Values zfin rejects or adjusts (`accounts.srf`, `projections.srf`)
|
||||
|
||||
Values that parse but can't be used as written. zfin replaces or clamps
|
||||
them so the file still loads -- just not the way you wrote it. Each of
|
||||
these is also logged to stderr when the file loads; the audit is where
|
||||
it gets a line number and can't scroll past.
|
||||
|
||||
**`accounts.srf`**
|
||||
|
||||
| Report | What zfin does instead |
|
||||
|-----------------------------------------------|-------------------------------------------------------------------------|
|
||||
| `audit_large_lot_threshold must be > 0` | Uses the audit's built-in threshold. |
|
||||
| `harvested must be a finite number` | Drops the harvested figure (only reachable by typing `inf` or `nan`). |
|
||||
| `tax_mix_* ...` (any of the four rules below) | Ignores the whole tax mix; the account counts wholly as its `tax_type`. |
|
||||
|
||||
The four tax-mix rules: every `tax_mix_*` carve-out must be above 0 and
|
||||
a finite number, none may name the account's own `tax_type`, and
|
||||
together they must sum to less than 100. See
|
||||
[`accounts.srf`](../reference/config/accounts-srf.md).
|
||||
|
||||
**`projections.srf`**
|
||||
|
||||
| Report | What zfin does instead |
|
||||
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| `return_cap`, `annual_contribution`, `target_spending`, or `survivor_spending_pct` `must be >= 0` | Ignores that value; an earlier line's value, or the default, stands. |
|
||||
| `horizon`, `horizon_age`, or `max_accumulation_years` `must be > 0` | Ignores that value. |
|
||||
| `spending_change capped` | Clamps it to 10%/yr either way -- a larger figure is almost always a units slip. |
|
||||
| `max_accumulation_years capped` | Clamps it to 100. |
|
||||
| `benchmark_stock` / `benchmark_bond` `must be 1..16 chars` | Ignores the symbol and keeps `SPY` / `AGG`. |
|
||||
| `retirement_target must be 90, 95, or 99` | Ignores the annotation on that horizon. |
|
||||
| `retirement_target set on multiple horizons` | Ignores **all** of them and uses the default promotion rule. Names both lines. |
|
||||
| `horizon limit` / `horizon_age limit` (more than 8 of either) | Ignores the extras. |
|
||||
| `event limit` (more than 16), or an event with `start_age 0` | Ignores that event. |
|
||||
| `birthdate person index ... exceeds limit` (more than 4 people) | Ignores that birthdate. |
|
||||
| `skipping malformed record` | Drops the whole line -- e.g. a misspelled `type::`, or text where a number belongs. |
|
||||
| `stopped reading` | Ignores that line **and everything after it**. |
|
||||
|
||||
The last two matter most: this file never fails to load, so a line zfin
|
||||
couldn't read otherwise just quietly reverts to defaults. See
|
||||
[`projections.srf`](../reference/config/projections-srf.md).
|
||||
|
||||
#### Also in `zfin doctor`
|
||||
|
||||
`zfin doctor` reports the same findings one file at a time, with a link
|
||||
to each file's reference page, and also covers `keys.srf`, `theme.srf`,
|
||||
and `acknowledgments.srf`. Neither command changes its exit code over
|
||||
any of these -- they're nudges, not failures.
|
||||
|
||||
## Auto-discovery (and your download folder)
|
||||
|
||||
With no `--fidelity`/`--schwab` flag, zfin looks for exports in two
|
||||
|
|
|
|||
|
|
@ -54,13 +54,29 @@ rather than counting toward the total:
|
|||
alongside the account's cash going down.
|
||||
- **Reallocating -- selling one holding to buy another in the same
|
||||
account.** The sale's proceeds offset the repurchase.
|
||||
- **A CD paying out.** A CD's payout counts like a sale: rolling it
|
||||
into a new CD, or buying with the payout, isn't new money.
|
||||
|
||||
A sale is valued at `close_price` when you record one (see
|
||||
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
|
||||
actually realized. If you delete the lot outright instead, there is no
|
||||
`close_price` to read and the current market price stands in -- accurate
|
||||
for a recent sale, less so for one made long before the end of the
|
||||
window. The report labels which was used: `at close` or `at mark`.
|
||||
actually realized. If you delete the lot outright instead, or record the
|
||||
close without a `close_price`, the current market price stands in --
|
||||
accurate for a recent sale, less so for one made long before the end of
|
||||
the window. The report labels which was used: `at close` only when every
|
||||
lot on the line had a `close_price`, otherwise `at mark`.
|
||||
|
||||
An option removed after it expired is worth $0 -- whether it expired
|
||||
worthless or was exercised, the premium isn't proceeds. (An exercise
|
||||
shows up separately, as the stock and cash that moved.)
|
||||
|
||||
A partial sale is recorded by splitting the lot: keep the shares you
|
||||
still hold on one line, and put the sold shares on another with
|
||||
`close_date` and `close_price`. Only the sold shares count, at their
|
||||
`close_price`, whichever order the lines are in and whether the sold one
|
||||
stays in `portfolio.srf` or moves to `portfolio_closed.srf`. Deleting a
|
||||
lot you had already closed changes nothing, and neither does adding old
|
||||
closed lots to the file -- though an added one appears in the report as
|
||||
a purchase funded by its own sale.
|
||||
|
||||
Closing a position that has accumulated a lot per dividend
|
||||
reinvestment retires many lots at once, so sales collapse to one line
|
||||
|
|
@ -71,6 +87,17 @@ funded anything, and are treated accordingly. On an account marked
|
|||
`cash_is_contribution::true` they also cancel that account's cash
|
||||
credit, since the sale is not new money even though cash arrived.
|
||||
|
||||
A CD that matures pays out in the window containing its maturity date
|
||||
-- between the dates of the two commits being compared, or today for
|
||||
uncommitted changes -- whether you close it,
|
||||
delete it, archive it to `portfolio_closed.srf`, or leave it where it
|
||||
is. Closing or removing it in a later window changes nothing. A CD
|
||||
redeemed before maturity pays out in the window you close or remove it.
|
||||
|
||||
On an account marked `cash_is_contribution::true`, update its cash
|
||||
balance in that same window: a payout that reaches the cash line a
|
||||
commit later reads as new money there.
|
||||
|
||||
Movement **between** accounts is a different matter -- zfin cannot tell
|
||||
it from a contribution, so declare it in
|
||||
[`transaction_log.srf`](../config/transaction-log-srf.md). An explicit
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ One binding per line:
|
|||
action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|--------|----------|---------------------------------------------------------------------------------------------------------|
|
||||
| `action` | string | Yes | The action to bind. Global actions are listed below; a tab-local `scope` changes which names are valid. |
|
||||
| `key` | string | Yes | The key combination, e.g. `q`, `ctrl+c`, `F5`, `page_down`. |
|
||||
| `scope` | string | No | Omitted or `global` for a global binding; a tab name (e.g. `options`) for a tab-local one. |
|
||||
|
||||
- **Modifiers:** `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+c`).
|
||||
- **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`,
|
||||
`left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`,
|
||||
|
|
@ -31,6 +37,12 @@ action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
|
|||
A tab-local binding may not reuse a globally-bound key; zfin refuses
|
||||
to start if you create that conflict.
|
||||
|
||||
A binding whose `scope` names no tab, or whose `action` isn't one of
|
||||
that tab's actions, never fires. It also doesn't fall back to the
|
||||
tab's default binding, because any override for a tab replaces all of
|
||||
its defaults. zfin prints a warning for each one when the TUI starts,
|
||||
and `zfin doctor` reports them with line numbers.
|
||||
|
||||
## Default global bindings
|
||||
|
||||
```srf
|
||||
|
|
|
|||
|
|
@ -34,14 +34,15 @@ symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::
|
|||
| Field | Type | Required | Description |
|
||||
|-----------------|--------|----------|---------------------------------------------------------------------------------------|
|
||||
| `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. |
|
||||
| `shares` | number | Yes | Share count (or face value for cash/CDs). Negative for short option positions. |
|
||||
| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Not required for `cash`/`watch`. |
|
||||
| `open_price` | number | Yes\*\* | Purchase price per share. \*\*Not required for `cash`/`watch`. |
|
||||
| `shares` | number | Yes\*\*\* | Share count. For `cash`, `cd` and `illiquid`, the dollar value (face value for CDs). Negative for short option positions. \*\*\*Optional for `watch`. |
|
||||
| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Optional for `cash`/`watch`; see [Minimal cash and watch lots](#minimal-cash-and-watch-lots). |
|
||||
| `open_price` | number | Yes\*\* | Purchase price per share; `1` for `cash`, `cd` and `illiquid`, whose `shares` are dollars. \*\*Optional for `cash`/`watch`. |
|
||||
| `close_date` | string | No | Sale date. Omit for an open lot. See [Closed lots](#closed-lots). |
|
||||
| `close_price` | number | No | Sale price per share. See [Closed lots](#closed-lots). |
|
||||
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. |
|
||||
| `account` | string | No | Account name. Should match an `account::` entry in [`accounts.srf`](accounts-srf.md). |
|
||||
| `note` | string | No | Free-text note (shown in cash/CD/illiquid tables). |
|
||||
| `label` | string | No | Display name for the symbol column, overriding `symbol`/`ticker` |
|
||||
| `ticker` | string | No | Ticker alias used for price fetching when `symbol` is a CUSIP. |
|
||||
| `price` | number | No | Manual price override (for securities the providers don't cover). |
|
||||
| `price_date` | string | No | Date of the manual price (`YYYY-MM-DD`), for staleness display. |
|
||||
|
|
@ -71,8 +72,8 @@ Each line below is valid on its own. These mirror the bundled
|
|||
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
|
||||
symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
|
||||
|
||||
# Cash (no symbol, no open_date/open_price needed)
|
||||
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
|
||||
# Cash (no symbol, open_date or open_price needed)
|
||||
security_type::cash,shares:num:48000.00,account::Joint taxable
|
||||
|
||||
# Closed (sold) lot
|
||||
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50
|
||||
|
|
@ -89,13 +90,33 @@ symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,pri
|
|||
# Option: a written (short) call. Negative shares = contracts sold.
|
||||
security_type::option,symbol::AAPL 06/20/2025 200.00 C,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,option_type::call,underlying::AAPL,strike:num:200,maturity_date::2025-06-20,account::Brokerage
|
||||
|
||||
# CD
|
||||
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
|
||||
# CD: shares is the face value, open_price is 1
|
||||
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:1,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
|
||||
|
||||
# Illiquid asset (net worth only)
|
||||
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence
|
||||
# Illiquid asset (net worth only): shares is the current value, open_price is 1
|
||||
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:1,note::Primary residence
|
||||
```
|
||||
|
||||
## Minimal cash and watch lots
|
||||
|
||||
A `cash` lot may leave out `open_date` and `open_price`, and a `watch`
|
||||
lot may also leave out `shares`:
|
||||
|
||||
```srf
|
||||
security_type::cash,shares:num:48000.00,account::Brokerage
|
||||
security_type::watch,symbol::NVDA
|
||||
```
|
||||
|
||||
zfin fills in what's missing the same way `zfin import` writes it:
|
||||
`open_date` 1970-01-01 and `open_price` 1 for cash, and zeros for a
|
||||
watch lot. A cash lot with no `open_date` therefore counts as held on
|
||||
every date, including back-dated `--as-of` views; give it one if that
|
||||
matters.
|
||||
|
||||
Every other type needs all three. A stock, option, CD or illiquid lot
|
||||
missing one is skipped, and `zfin doctor` names the field, e.g.
|
||||
`open_price is required for stock lots`.
|
||||
|
||||
## Closed lots
|
||||
|
||||
A lot with `close_date` set is sold. Both fields are optional -- an open
|
||||
|
|
@ -110,6 +131,25 @@ two things beyond bookkeeping:
|
|||
`close_price` the current market price stands in, which is close
|
||||
enough for a recent sale and wrong for an old one.
|
||||
|
||||
A `close_date` must not be in the future. A close dated today is already
|
||||
closed, but a later date leaves the lot counted as held -- everywhere,
|
||||
including `zfin contributions` -- until that day. For a scheduled end (a
|
||||
CD or option), use `maturity_date`.
|
||||
|
||||
A `close_price` only counts once its `close_date` has arrived. Until
|
||||
then the lot is held and priced live, like any other held lot.
|
||||
|
||||
`zfin audit` and `zfin doctor` flag these lot-lifecycle mistakes:
|
||||
|
||||
- `close_date` in the future, or before `open_date`.
|
||||
- `close_date` without `close_price` on a stock lot (the realized gain
|
||||
is recorded as 0), or `close_price` without `close_date` on any lot.
|
||||
- `open_date` in the future (the lot is not counted until then).
|
||||
- `price_date` in the future on a stock lot (the manual price is never
|
||||
flagged as stale).
|
||||
|
||||
Watch lots are exempt; zfin reads only their `symbol`.
|
||||
|
||||
You can either edit the lot where it sits or move it to a sibling file
|
||||
such as `portfolio_closed.srf` -- the `portfolio*.srf` glob picks it up
|
||||
either way, so realized gains and back-dated (`--as-of`) views stay
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ negative::#e06c75
|
|||
| `info` | Informational highlights |
|
||||
| `select_bg` / `select_fg` | Selected row |
|
||||
| `border` | Borders and rules |
|
||||
| `bar_fill` | Bar-chart fill |
|
||||
|
||||
Run `zfin interactive --default-theme` for the full key list with the
|
||||
default values filled in.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ transfer::2026-05-02,type::cash,amount:num:4700,from::Sample IRA,to::Sample Brok
|
|||
| `from` | string | Yes | Source account name (matches an `account::` in your portfolio). |
|
||||
| `to` | string | Yes | Destination account name. |
|
||||
| `dest_lot` | string | Yes | Where it landed: `cash`, or `SYMBOL@YYYY-MM-DD` for a specific lot. |
|
||||
| `note` | string | No | Free-text reminder of why the transfer happened. |
|
||||
|
||||
## `type::cash` vs `type::in_kind`
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
|
|||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||
const Date = @import("../Date.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const srf_lint = @import("../srf_lint.zig");
|
||||
|
||||
const log = std.log.scoped(.accounts);
|
||||
|
||||
|
|
@ -368,6 +369,94 @@ pub const AccountTaxEntry = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// Every semantic problem `accounts.srf` validation can find in one
|
||||
/// entry. All three fall back to a safe default rather than failing the
|
||||
/// parse, which is exactly why they need reporting: the account still
|
||||
/// loads, just not the way the user wrote it.
|
||||
///
|
||||
/// Single source of truth for these rules. `parseAccountsFile` logs
|
||||
/// them to stderr as it parses; `srf_schema.semanticCheck` surfaces the
|
||||
/// same set through `zfin doctor` / `zfin audit`, where they are
|
||||
/// attached to a line number and cannot scroll past. Both call
|
||||
/// `checkEntry` - the conditions are written once.
|
||||
pub const EntryProblems = struct {
|
||||
/// `audit_large_lot_threshold` was zero or negative. Zero would
|
||||
/// flag every new lot; negative is meaningless.
|
||||
bad_lot_threshold: ?f64 = null,
|
||||
/// `harvested` was `inf`/`nan` - only reachable by hand-typing it.
|
||||
non_finite_harvested: ?f64 = null,
|
||||
/// A declared `tax_mix_*` set that breaks the carve-out rules, so
|
||||
/// the account reverts to its bare `tax_type`.
|
||||
tax_mix: ?TaxMixProblem = null,
|
||||
|
||||
pub fn any(self: EntryProblems) bool {
|
||||
return self.bad_lot_threshold != null or
|
||||
self.non_finite_harvested != null or
|
||||
self.tax_mix != null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Validate one parsed `accounts.srf` entry. Pure - no logging, no
|
||||
/// allocation - so both the parser and the linter can call it.
|
||||
pub fn checkEntry(entry: AccountTaxEntry) EntryProblems {
|
||||
var p: EntryProblems = .{};
|
||||
if (entry.audit_large_lot_threshold) |t| {
|
||||
if (t <= 0) p.bad_lot_threshold = t;
|
||||
}
|
||||
if (entry.harvested) |h| {
|
||||
if (!std.math.isFinite(h)) p.non_finite_harvested = h;
|
||||
}
|
||||
p.tax_mix = entry.taxMixChecked().problem;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Schema contract for `accounts.srf`. See `srf_lint.validateSchema`.
|
||||
///
|
||||
/// No `field_rules`: nothing here is conditional on another field's
|
||||
/// value. The `tax_mix_*` family IS interdependent, but that is a
|
||||
/// field-COMBINATION rule rather than a per-field applicability one, so
|
||||
/// it goes through `semanticCheck` instead.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = AccountTaxEntry;
|
||||
pub const file_label: []const u8 = "accounts.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/accounts-srf.md";
|
||||
|
||||
/// Surface `checkEntry`'s findings through the lint channel.
|
||||
///
|
||||
/// These were already detected at parse time and logged with
|
||||
/// `std.log.warn`, which still happens - but a stderr line emitted
|
||||
/// mid-render, with no file or line number, is how they came to be
|
||||
/// ignored. Reporting them here attaches each to the record that
|
||||
/// caused it and puts it in a report the user is reading on purpose.
|
||||
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
|
||||
// No date rules here: `doctor`'s Section B already reports a
|
||||
// future `harvested_date` / `tax_mix_date`, grouped by account.
|
||||
_ = ctx;
|
||||
const p = checkEntry(rec);
|
||||
if (p.bad_lot_threshold) |t| {
|
||||
try sink.addOwned(.semantic, "audit_large_lot_threshold", try std.fmt.allocPrint(
|
||||
sink.allocator,
|
||||
"account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignored, so the audit uses its default",
|
||||
.{ rec.account, t },
|
||||
));
|
||||
}
|
||||
if (p.non_finite_harvested) |h| {
|
||||
try sink.addOwned(.semantic, "harvested", try std.fmt.allocPrint(
|
||||
sink.allocator,
|
||||
"account '{s}': harvested must be a finite number (got {d}); ignored",
|
||||
.{ rec.account, h },
|
||||
));
|
||||
}
|
||||
if (p.tax_mix) |problem| {
|
||||
try sink.addOwned(.semantic, "tax_mix", try std.fmt.allocPrint(
|
||||
sink.allocator,
|
||||
"account '{s}': {s}; the tax mix is ignored and the account counts wholly as {s}",
|
||||
.{ rec.account, problem.label(), @tagName(rec.tax_type) },
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||||
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
|
||||
pub const UpdateCadence = enum {
|
||||
|
|
@ -559,39 +648,43 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
continue;
|
||||
};
|
||||
|
||||
// Validate once, report twice: these same problems are surfaced
|
||||
// with line numbers by `srf_schema.semanticCheck` (see
|
||||
// `src/srf_lint.zig`). The conditions live in `checkEntry` so
|
||||
// the two channels cannot disagree about what is wrong.
|
||||
const problems = checkEntry(entry);
|
||||
|
||||
// A zero/negative large-lot threshold is nonsensical (zero
|
||||
// flags every new lot; negative is meaningless). Reject it and
|
||||
// treat the account as unset so the audit uses its default.
|
||||
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
|
||||
if (t > 0) break :blk t;
|
||||
const lot_threshold: ?f64 = if (problems.bad_lot_threshold) |t| blk: {
|
||||
// No-op under `zig build test`: the parser's own tests feed
|
||||
// invalid thresholds (0, negative) on purpose to verify they
|
||||
// are rejected, and the warn spam pollutes test output.
|
||||
if (!builtin.is_test)
|
||||
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
|
||||
break :blk null;
|
||||
} else null;
|
||||
} else entry.audit_large_lot_threshold;
|
||||
|
||||
// `harvested` is a magnitude: the annotation's parens carry the
|
||||
// "this is a loss" convention, so accept either sign from the
|
||||
// user and normalize. A non-finite value can only arrive from a
|
||||
// hand-typed `inf`/`nan`; there is nothing sensible to display
|
||||
// for it, so drop the field rather than render garbage.
|
||||
const harvested: ?f64 = if (entry.harvested) |h| blk: {
|
||||
if (std.math.isFinite(h)) break :blk @abs(h);
|
||||
const harvested: ?f64 = if (problems.non_finite_harvested) |h| blk: {
|
||||
// Silent under `zig build test`: the parser's own tests feed
|
||||
// non-finite values on purpose, and the warn spam pollutes
|
||||
// test output.
|
||||
if (!builtin.is_test)
|
||||
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
|
||||
break :blk null;
|
||||
} else null;
|
||||
} else if (entry.harvested) |h| @abs(h) else null;
|
||||
|
||||
// A declared tax mix that breaks the rules falls back to the
|
||||
// bare `tax_type`. Warn so a typo isn't invisible; the raw
|
||||
// fields are deliberately left in place so `zfin doctor` can
|
||||
// report the same problem against the file the user edits.
|
||||
if (entry.taxMixChecked().problem) |p| {
|
||||
if (problems.tax_mix) |p| {
|
||||
// Silent under `zig build test`: the parser's own tests feed
|
||||
// invalid mixes on purpose to verify the fallback, and the
|
||||
// warn spam pollutes test output.
|
||||
|
|
@ -3318,3 +3411,91 @@ test "abbreviateSector: known long labels collapse, others pass through" {
|
|||
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
|
||||
try std.testing.expectEqualStrings("", abbreviateSector(""));
|
||||
}
|
||||
|
||||
// ── checkEntry / srf_schema.semanticCheck ──
|
||||
|
||||
/// Lint `data` as accounts.srf and return only the semantic findings
|
||||
/// (the raw field-name pass is `srf_lint`'s own territory).
|
||||
fn lintAccounts(data: []const u8) !srf_lint.Result {
|
||||
var r = try srf_lint.check(std.testing.allocator, data, srf_lint.shapeOfSchema(srf_schema));
|
||||
errdefer r.deinit();
|
||||
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = Date.fromYmd(2026, 1, 1) });
|
||||
return r;
|
||||
}
|
||||
|
||||
test "checkEntry: a clean entry has no problems" {
|
||||
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional };
|
||||
try std.testing.expect(!checkEntry(e).any());
|
||||
}
|
||||
|
||||
test "checkEntry: zero and negative large-lot thresholds are rejected, positive kept" {
|
||||
const zero = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 0 };
|
||||
try std.testing.expectEqual(@as(?f64, 0), checkEntry(zero).bad_lot_threshold);
|
||||
|
||||
const neg = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = -5 };
|
||||
try std.testing.expectEqual(@as(?f64, -5), checkEntry(neg).bad_lot_threshold);
|
||||
|
||||
const ok = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 25_000 };
|
||||
try std.testing.expectEqual(@as(?f64, null), checkEntry(ok).bad_lot_threshold);
|
||||
}
|
||||
|
||||
test "checkEntry: non-finite harvested is rejected, a negative magnitude is not" {
|
||||
const inf = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.inf(f64) };
|
||||
try std.testing.expect(checkEntry(inf).non_finite_harvested != null);
|
||||
|
||||
const nan = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.nan(f64) };
|
||||
try std.testing.expect(checkEntry(nan).non_finite_harvested != null);
|
||||
|
||||
// A negative figure is legal - the parser normalizes with @abs.
|
||||
const neg = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = -1234.0 };
|
||||
try std.testing.expectEqual(@as(?f64, null), checkEntry(neg).non_finite_harvested);
|
||||
}
|
||||
|
||||
test "checkEntry: a tax mix naming the account's own tax_type is a problem" {
|
||||
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional, .tax_mix_traditional = 30 };
|
||||
try std.testing.expectEqual(TaxMixProblem.redundant_primary, checkEntry(e).tax_mix.?);
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: reports the bad large-lot threshold with the account name" {
|
||||
var r = try lintAccounts("#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:0\n");
|
||||
defer r.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
|
||||
try std.testing.expectEqual(srf_lint.Kind.semantic, r.findings[0].kind);
|
||||
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
const msg = r.findings[0].describe(&buf);
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Brokerage") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "must be > 0") != null);
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: reports non-finite harvested" {
|
||||
var r = try lintAccounts("#!srfv1\naccount::Sample Trust,tax_type::taxable,harvested::inf\n");
|
||||
defer r.deinit();
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
|
||||
const msg = r.findings[0].describe(&buf);
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Trust") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "finite") != null);
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: reports a broken tax mix and names the fallback" {
|
||||
var r = try lintAccounts("#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_traditional:num:30\n");
|
||||
defer r.deinit();
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
|
||||
const msg = r.findings[0].describe(&buf);
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample IRA") != null);
|
||||
// The fallback behavior is the actionable part, not just "invalid".
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "counts wholly as traditional") != null);
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: a clean accounts.srf reports nothing" {
|
||||
var r = try lintAccounts(
|
||||
\\#!srfv1
|
||||
\\account::Sample Brokerage,tax_type::taxable,harvested:num:1200,harvested_date::2025-01-01
|
||||
\\account::Sample IRA,tax_type::traditional,tax_mix_roth:num:20
|
||||
\\
|
||||
);
|
||||
defer r.deinit();
|
||||
try std.testing.expect(r.isClean());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,19 +15,45 @@ const log = std.log.scoped(.projections);
|
|||
const shiller = @import("../data/shiller.zig");
|
||||
const srf = @import("srf");
|
||||
const srf_opts = @import("../srf_opts.zig");
|
||||
const srf_lint = @import("../srf_lint.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
/// `log.warn` wrapper that no-ops under `zig build test`. Used for
|
||||
/// validation warnings emitted while parsing user-supplied
|
||||
/// `projections.srf` records: the test suite intentionally feeds
|
||||
/// invalid inputs to `validRetirementTarget` and the config-loader
|
||||
/// to verify they're rejected, but the resulting stderr noise
|
||||
/// pollutes test output. Keep using `log.warn` directly when the
|
||||
/// warning is interesting in tests too.
|
||||
fn warnUser(comptime fmt: []const u8, args: anytype) void {
|
||||
if (builtin.is_test) return;
|
||||
log.warn(fmt, args);
|
||||
}
|
||||
/// Where `projections.srf` validation problems go.
|
||||
///
|
||||
/// The parser is the ONLY definition of these rules. Several depend on
|
||||
/// state across the whole file - the horizon / horizon_age / event /
|
||||
/// person limits, and "at most one `retirement_target`" - so a separate
|
||||
/// lint copy would have to re-implement the parse loop, and could then
|
||||
/// drift from it. Instead the one loop reports through this, either way:
|
||||
///
|
||||
/// - no sink (every normal load): `log.warn`, as it always has. Silent
|
||||
/// under `zig build test`, whose fixtures feed invalid values on
|
||||
/// purpose.
|
||||
/// - a sink (`srf_schema.fileCheck`, run by `zfin doctor` and
|
||||
/// `zfin audit`): a lint finding carrying the record's line number,
|
||||
/// and nothing on stderr.
|
||||
///
|
||||
/// `args: anytype` is a `std.fmt` argument tuple, forwarded unchanged to
|
||||
/// `log.warn` / `std.fmt.allocPrint` - the same shape as `std.log`
|
||||
/// itself, and as the `warnUser` helper this replaced.
|
||||
const Reporter = struct {
|
||||
sink: ?*srf_lint.Sink = null,
|
||||
|
||||
/// Stamp subsequent findings with `line`. No-op without a sink.
|
||||
fn at(self: Reporter, line: u32) void {
|
||||
if (self.sink) |s| s.line = line;
|
||||
}
|
||||
|
||||
/// `key` is the field the problem is about. It is used for rollup
|
||||
/// identity only - the message itself names it too.
|
||||
fn warn(self: Reporter, key: []const u8, comptime fmt: []const u8, args: anytype) error{OutOfMemory}!void {
|
||||
if (self.sink) |s| {
|
||||
try s.addOwned(.semantic, key, try std.fmt.allocPrint(s.allocator, fmt, args));
|
||||
} else if (!builtin.is_test) {
|
||||
log.warn("projections: " ++ fmt, args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Life events ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -722,6 +748,31 @@ const SrfProjection = union(enum) {
|
|||
event: SrfEvent,
|
||||
};
|
||||
|
||||
/// Schema contract for `projections.srf`. See
|
||||
/// `srf_lint.validateSchema`.
|
||||
///
|
||||
/// The highest-value file in the registry to lint: all 17 `SrfConfig`
|
||||
/// fields have defaults, so SRF silently discards a typo'd one, and
|
||||
/// `parseProjectionsConfig` has an infallible signature that degrades
|
||||
/// every failure to defaults. A misspelled `target_spending` therefore
|
||||
/// changes projected retirement math with no output whatsoever.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = SrfProjection;
|
||||
pub const file_label: []const u8 = "projections.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/projections-srf.md";
|
||||
|
||||
/// Run the real parser with a sink attached, so every rule it
|
||||
/// enforces is reported with a line number instead of logged. See
|
||||
/// `Reporter` for why this is a `fileCheck` (whole file) rather than
|
||||
/// a per-record `semanticCheck`: half the rules are limits and
|
||||
/// "at most one" constraints that span records.
|
||||
pub fn fileCheck(data: []const u8, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
|
||||
// No date rules in this file, so `today` is unused.
|
||||
_ = ctx;
|
||||
_ = try parseWith(data, .{ .sink = sink });
|
||||
}
|
||||
};
|
||||
|
||||
/// Clamp on the magnitude of `spending_change` (10%/yr real, in
|
||||
/// either direction). A larger drift is almost certainly a units
|
||||
/// typo - someone entering a fraction (0.02) where a whole percent
|
||||
|
|
@ -782,6 +833,18 @@ pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8)
|
|||
/// type::birthdate,date::1975-03-15
|
||||
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
|
||||
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
||||
// With no sink the reporter only logs, and nothing else in the parse
|
||||
// can fail, so OutOfMemory is unreachable. An exhaustive switch
|
||||
// rather than `catch unreachable`: a new error in `parseWith`'s set
|
||||
// becomes a compile error here instead of a silent assumption.
|
||||
return parseWith(data, .{}) catch |err| switch (err) {
|
||||
error.OutOfMemory => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
/// `parseProjectionsConfig`, reporting validation problems through
|
||||
/// `rep`. Only a sink-backed reporter can fail (allocating a finding).
|
||||
fn parseWith(data: ?[]const u8, rep: Reporter) error{OutOfMemory}!UserConfig {
|
||||
var config = UserConfig{};
|
||||
const raw = data orelse return config;
|
||||
if (raw.len == 0) return config;
|
||||
|
|
@ -803,17 +866,28 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
// rule. A single bad value (not in {90,95,99}) is treated as
|
||||
// "no annotation on this record" and doesn't poison the others.
|
||||
var annotation_count: u8 = 0;
|
||||
// Where the first two annotations were, so the "more than one"
|
||||
// finding can point at both.
|
||||
var first_annotation_line: u32 = 0;
|
||||
var second_annotation_line: u32 = 0;
|
||||
|
||||
while (true) {
|
||||
const next = it.next() catch |err| {
|
||||
// Used to be `catch null`, which dropped the rest of the
|
||||
// file without a word.
|
||||
rep.at(@intCast(it.state.line));
|
||||
try rep.warn("record", "stopped reading ({s}); this and every later record are ignored", .{@errorName(err)});
|
||||
break;
|
||||
};
|
||||
const field_it = next orelse break;
|
||||
const line: u32 = @intCast(it.state.line);
|
||||
rep.at(line);
|
||||
|
||||
while (it.next() catch null) |field_it| {
|
||||
const rec = field_it.to(SrfProjection, srf_opts.user_edited) catch |err| {
|
||||
// Skip the record rather than losing the whole file, but
|
||||
// name the error: a dropped record reverts that setting to
|
||||
// its default without saying so. Quiet under
|
||||
// `zig build test`, where fixtures feed malformed records
|
||||
// on purpose.
|
||||
if (!builtin.is_test) {
|
||||
log.warn("projections.srf: skipping malformed record: {s}", .{@errorName(err)});
|
||||
}
|
||||
// its default without saying so.
|
||||
try rep.warn("record", "skipping malformed record ({s}); its settings revert to defaults", .{@errorName(err)});
|
||||
continue;
|
||||
};
|
||||
switch (rec) {
|
||||
|
|
@ -827,7 +901,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
if (cap >= 0) {
|
||||
config.return_cap = cap;
|
||||
} else {
|
||||
warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
|
||||
try rep.warn("return_cap", "return_cap must be >= 0 (got {d}); value ignored", .{cap});
|
||||
}
|
||||
}
|
||||
if (c.horizon) |h| {
|
||||
|
|
@ -836,14 +910,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
saw_horizon = true;
|
||||
}
|
||||
if (h == 0) {
|
||||
log.warn("projections: horizon must be > 0; ignoring record", .{});
|
||||
try rep.warn("horizon", "horizon must be > 0; value ignored", .{});
|
||||
} else if (config.horizon_count >= UserConfig.max_horizons) {
|
||||
log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h });
|
||||
try rep.warn("horizon", "horizon limit reached ({d}); ignoring extra horizon (value {d})", .{ UserConfig.max_horizons, h });
|
||||
} else {
|
||||
config.horizons[config.horizon_count] = h;
|
||||
if (validRetirementTarget(c.retirement_target)) |conf| {
|
||||
if (try validRetirementTarget(c.retirement_target, rep)) |conf| {
|
||||
config.horizon_targets[config.horizon_count] = conf;
|
||||
annotation_count += 1;
|
||||
if (annotation_count == 1) first_annotation_line = line;
|
||||
if (annotation_count == 2) second_annotation_line = line;
|
||||
}
|
||||
config.horizon_count += 1;
|
||||
}
|
||||
|
|
@ -860,14 +936,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
saw_horizon = true;
|
||||
}
|
||||
if (age == 0) {
|
||||
log.warn("projections: horizon_age must be > 0; ignoring record", .{});
|
||||
try rep.warn("horizon_age", "horizon_age must be > 0; value ignored", .{});
|
||||
} else if (config.horizon_age_count >= UserConfig.max_horizons) {
|
||||
log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age });
|
||||
try rep.warn("horizon_age", "horizon_age limit reached ({d}); ignoring extra horizon_age (value {d})", .{ UserConfig.max_horizons, age });
|
||||
} else {
|
||||
config.horizon_ages[config.horizon_age_count] = age;
|
||||
if (validRetirementTarget(c.retirement_target)) |conf| {
|
||||
if (try validRetirementTarget(c.retirement_target, rep)) |conf| {
|
||||
config.horizon_age_targets[config.horizon_age_count] = conf;
|
||||
annotation_count += 1;
|
||||
if (annotation_count == 1) first_annotation_line = line;
|
||||
if (annotation_count == 2) second_annotation_line = line;
|
||||
}
|
||||
config.horizon_age_count += 1;
|
||||
}
|
||||
|
|
@ -880,7 +958,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
if (amt >= 0) {
|
||||
config.annual_contribution = amt;
|
||||
} else {
|
||||
warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt});
|
||||
try rep.warn("annual_contribution", "annual_contribution must be >= 0 (got {d}); value ignored", .{amt});
|
||||
}
|
||||
}
|
||||
if (c.contribution_inflation_adjusted) |b| {
|
||||
|
|
@ -890,7 +968,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
if (amt >= 0) {
|
||||
config.target_spending = amt;
|
||||
} else {
|
||||
warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
|
||||
try rep.warn("target_spending", "target_spending must be >= 0 (got {d}); value ignored", .{amt});
|
||||
}
|
||||
}
|
||||
if (c.target_spending_inflation_adjusted) |b| {
|
||||
|
|
@ -904,10 +982,10 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
const frac = pct / 100.0;
|
||||
const cap = max_abs_spending_real_change;
|
||||
if (frac > cap) {
|
||||
warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
|
||||
try rep.warn("spending_change", "spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
|
||||
config.spending_real_change = cap;
|
||||
} else if (frac < -cap) {
|
||||
warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
|
||||
try rep.warn("spending_change", "spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
|
||||
config.spending_real_change = -cap;
|
||||
} else {
|
||||
config.spending_real_change = frac;
|
||||
|
|
@ -923,7 +1001,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
if (pct >= 0) {
|
||||
config.survivor_spending_pct = pct;
|
||||
} else {
|
||||
warnUser("projections: survivor_spending_pct must be >= 0 (got {d}); ignoring record", .{pct});
|
||||
try rep.warn("survivor_spending_pct", "survivor_spending_pct must be >= 0 (got {d}); value ignored", .{pct});
|
||||
}
|
||||
}
|
||||
if (c.max_accumulation_years) |n| {
|
||||
|
|
@ -931,12 +1009,12 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
// A zero-year search ceiling is degenerate (it
|
||||
// would only ever ask "can I retire today?").
|
||||
// Almost certainly a typo; keep the default.
|
||||
warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
|
||||
try rep.warn("max_accumulation_years", "max_accumulation_years must be > 0; value ignored", .{});
|
||||
} else if (n > max_configurable_accumulation_years) {
|
||||
// Respect the intent (the user wants a large
|
||||
// ceiling) but clamp to keep the search bounded
|
||||
// and inside the historical data span.
|
||||
warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
|
||||
try rep.warn("max_accumulation_years", "max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
|
||||
config.max_accumulation_years = max_configurable_accumulation_years;
|
||||
} else {
|
||||
config.max_accumulation_years = n;
|
||||
|
|
@ -944,7 +1022,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
}
|
||||
if (c.benchmark_stock) |sym| {
|
||||
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
|
||||
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
|
||||
try rep.warn("benchmark_stock", "benchmark_stock must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_stock_buf.len, sym.len });
|
||||
} else {
|
||||
// Copy into our own buffer + length so the value
|
||||
// outlives both the SRF iterator's backing data
|
||||
|
|
@ -958,7 +1036,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
}
|
||||
if (c.benchmark_bond) |sym| {
|
||||
if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
|
||||
warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
|
||||
try rep.warn("benchmark_bond", "benchmark_bond must be 1..{d} chars (got {d}); value ignored", .{ config.benchmark_bond_buf.len, sym.len });
|
||||
} else {
|
||||
@memcpy(config.benchmark_bond_buf[0..sym.len], sym);
|
||||
config.benchmark_bond_len = @intCast(sym.len);
|
||||
|
|
@ -973,15 +1051,15 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
config.birthdates[idx] = b.date;
|
||||
if (idx >= config.birthdate_count) config.birthdate_count = idx + 1;
|
||||
} else {
|
||||
log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
|
||||
try rep.warn("person", "birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
|
||||
}
|
||||
birthdate_seq += 1;
|
||||
},
|
||||
.event => |e| {
|
||||
if (e.start_age == 0) {
|
||||
log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name});
|
||||
try rep.warn("start_age", "event '{s}' has start_age 0; ignoring record", .{e.name});
|
||||
} else if (config.event_count >= UserConfig.max_events) {
|
||||
log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
|
||||
try rep.warn("event", "event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
|
||||
} else {
|
||||
var ev = LifeEvent{
|
||||
.start_age = e.start_age,
|
||||
|
|
@ -1006,7 +1084,8 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
// fall back to the default rule. Logged as a warning so the
|
||||
// user knows their override was ignored.
|
||||
if (annotation_count > 1) {
|
||||
warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{});
|
||||
rep.at(second_annotation_line);
|
||||
try rep.warn("retirement_target", "retirement_target set on multiple horizons (first on line {d}); ignoring all annotations and using default promotion rule", .{first_annotation_line});
|
||||
config.horizon_targets = @splat(0);
|
||||
config.horizon_age_targets = @splat(0);
|
||||
}
|
||||
|
|
@ -1016,13 +1095,13 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
|
||||
/// Validate a `retirement_target` SRF value. Returns the value
|
||||
/// unchanged if it's exactly 90, 95, or 99; returns null otherwise
|
||||
/// (logged as a warning so the user notices the typo). Used at parse
|
||||
/// (reported through `rep` so the user notices the typo). Used at parse
|
||||
/// time; the view-layer `pickPromotedCell` trusts whatever lands in
|
||||
/// `horizon_targets`.
|
||||
fn validRetirementTarget(raw: ?u8) ?u8 {
|
||||
fn validRetirementTarget(raw: ?u8, rep: Reporter) error{OutOfMemory}!?u8 {
|
||||
const v = raw orelse return null;
|
||||
if (v == 90 or v == 95 or v == 99) return v;
|
||||
warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
|
||||
try rep.warn("retirement_target", "retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -4000,13 +4079,13 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
|
|||
}
|
||||
|
||||
test "validRetirementTarget: 90/95/99 pass, others fail" {
|
||||
try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90));
|
||||
try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95));
|
||||
try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99));
|
||||
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null));
|
||||
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0));
|
||||
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85));
|
||||
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100));
|
||||
try std.testing.expectEqual(@as(?u8, 90), try validRetirementTarget(90, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, 95), try validRetirementTarget(95, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, 99), try validRetirementTarget(99, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(null, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(0, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(85, .{}));
|
||||
try std.testing.expectEqual(@as(?u8, null), try validRetirementTarget(100, .{}));
|
||||
}
|
||||
|
||||
// ── oldestBirthdate / oldestAge tests ──────────────────────────
|
||||
|
|
@ -4450,3 +4529,231 @@ test "benchmarkSymbols: an override is honoured and outlives the config" {
|
|||
try std.testing.expectEqualStrings("VTI", pair[0]);
|
||||
try std.testing.expectEqualStrings("BND", pair[1]);
|
||||
}
|
||||
|
||||
// ── srf_schema.fileCheck: parse-time rules reported through the lint ──
|
||||
|
||||
/// Run both lint passes over `data` as projections.srf and return the
|
||||
/// semantic findings as "line: message" lines, in line order. Caller
|
||||
/// frees.
|
||||
fn lintProjections(data: []const u8) ![]u8 {
|
||||
const allocator = std.testing.allocator;
|
||||
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
|
||||
defer r.deinit();
|
||||
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = Date.fromYmd(2026, 1, 1) });
|
||||
r.sort();
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
errdefer aw.deinit();
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
for (r.findings) |f| {
|
||||
if (f.kind != .semantic) continue;
|
||||
try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
|
||||
}
|
||||
return aw.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn expectProjectionFindings(data: []const u8, want: []const u8) !void {
|
||||
const got = try lintProjections(data);
|
||||
defer std.testing.allocator.free(got);
|
||||
try std.testing.expectEqualStrings(want, got);
|
||||
}
|
||||
|
||||
test "fileCheck: a clean projections.srf reports nothing" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,target_stock_pct:num:80,return_cap:num:30
|
||||
\\type::config,horizon:num:30,retirement_target:num:95
|
||||
\\type::config,horizon_age:num:95
|
||||
\\type::config,spending_change:num:-2,survivor_spending_pct:num:75
|
||||
\\type::birthdate,date::1975-03-15
|
||||
\\type::event,name::Social Security,start_age:num:67,amount:num:38400
|
||||
\\
|
||||
, "");
|
||||
}
|
||||
|
||||
test "fileCheck: negative amounts are rejected" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,return_cap:num:-5
|
||||
\\type::config,annual_contribution:num:-100
|
||||
\\type::config,target_spending:num:-1
|
||||
\\type::config,survivor_spending_pct:num:-10
|
||||
\\
|
||||
,
|
||||
\\2: return_cap must be >= 0 (got -5); value ignored
|
||||
\\3: annual_contribution must be >= 0 (got -100); value ignored
|
||||
\\4: target_spending must be >= 0 (got -1); value ignored
|
||||
\\5: survivor_spending_pct must be >= 0 (got -10); value ignored
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: zero horizons and a zero accumulation ceiling are rejected" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon:num:0
|
||||
\\type::config,horizon_age:num:0
|
||||
\\type::config,max_accumulation_years:num:0
|
||||
\\
|
||||
,
|
||||
\\2: horizon must be > 0; value ignored
|
||||
\\3: horizon_age must be > 0; value ignored
|
||||
\\4: max_accumulation_years must be > 0; value ignored
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: out-of-range values report the clamp that was applied" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,spending_change:num:20
|
||||
\\type::config,spending_change:num:-20
|
||||
\\type::config,max_accumulation_years:num:150
|
||||
\\
|
||||
,
|
||||
\\2: spending_change capped at +10%/yr (got 20%)
|
||||
\\3: spending_change capped at -10%/yr (got -20%)
|
||||
\\4: max_accumulation_years capped at 100 (got 150)
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: benchmark symbols longer than the buffer are rejected" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
|
||||
\\type::config,benchmark_bond::ABCDEFGHIJKLMNOPQ
|
||||
\\
|
||||
,
|
||||
\\2: benchmark_stock must be 1..16 chars (got 17); value ignored
|
||||
\\3: benchmark_bond must be 1..16 chars (got 17); value ignored
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: an invalid retirement_target is ignored" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon:num:30,retirement_target:num:85
|
||||
\\
|
||||
,
|
||||
\\2: retirement_target must be 90, 95, or 99 (got 85); annotation ignored
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: retirement_target on two horizons points at both" {
|
||||
// A cross-record rule - the reason this is a `fileCheck` and not a
|
||||
// per-record `semanticCheck`.
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon:num:30,retirement_target:num:95
|
||||
\\type::config,horizon_age:num:90,retirement_target:num:99
|
||||
\\
|
||||
,
|
||||
\\3: retirement_target set on multiple horizons (first on line 2); ignoring all annotations and using default promotion rule
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: horizon and horizon_age limits" {
|
||||
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||
defer aw.deinit();
|
||||
try aw.writer.writeAll("#!srfv1\n");
|
||||
for (0..UserConfig.max_horizons + 1) |i| try aw.writer.print("type::config,horizon:num:{d}\n", .{10 + i});
|
||||
for (0..UserConfig.max_horizons + 1) |i| try aw.writer.print("type::config,horizon_age:num:{d}\n", .{80 + i});
|
||||
// Line 1 is the header, so the ninth horizon is line 10 and the
|
||||
// ninth horizon_age is line 19.
|
||||
try expectProjectionFindings(aw.written(),
|
||||
\\10: horizon limit reached (8); ignoring extra horizon (value 18)
|
||||
\\19: horizon_age limit reached (8); ignoring extra horizon_age (value 88)
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: birthdate beyond the person limit" {
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::birthdate,date::1980-01-01,person:num:5
|
||||
\\
|
||||
,
|
||||
\\2: birthdate person index 5 exceeds limit (4); ignoring record
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: event with start_age 0, and the event limit" {
|
||||
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||
defer aw.deinit();
|
||||
try aw.writer.writeAll("#!srfv1\ntype::event,name::Pension,amount:num:100\n");
|
||||
for (0..UserConfig.max_events + 1) |i| {
|
||||
try aw.writer.print("type::event,name::Event {d},start_age:num:{d},amount:num:1\n", .{ i, 60 + i });
|
||||
}
|
||||
// Line 2 has no start_age (defaults to 0). Lines 3..18 fill the 16
|
||||
// slots; line 19 is the seventeenth.
|
||||
try expectProjectionFindings(aw.written(),
|
||||
\\2: event 'Pension' has start_age 0; ignoring record
|
||||
\\19: event limit reached (16); ignoring extra event 'Event 16'
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: a record that fails to coerce is reported, not silently dropped" {
|
||||
// `doctor`'s parse-check for this file is structural only, because
|
||||
// the parser is infallible - so before this, nothing reported it.
|
||||
try expectProjectionFindings(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon::thirty
|
||||
\\type::confg,horizon:num:30
|
||||
\\
|
||||
,
|
||||
\\2: skipping malformed record (InvalidCharacter); its settings revert to defaults
|
||||
\\3: skipping malformed record (ActiveTagDoesNotExist); its settings revert to defaults
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "fileCheck: a stream error is reported instead of truncating the file silently" {
|
||||
// A directive after data is a hard SRF error; the parser used to
|
||||
// `catch null` it and quietly drop every record after it.
|
||||
const got = try lintProjections(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon:num:30
|
||||
\\#!expires=1767225600
|
||||
\\type::config,target_stock_pct:num:60
|
||||
\\
|
||||
);
|
||||
defer std.testing.allocator.free(got);
|
||||
try std.testing.expect(std.mem.indexOf(u8, got, "stopped reading (ParseFailed); this and every later record are ignored") != null);
|
||||
}
|
||||
|
||||
test "fileCheck: attaching a sink does not change what the parser produces" {
|
||||
// The lint runs the real parser; it must not also alter it. Same
|
||||
// bad input through both modes, compared field by field.
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,return_cap:num:-5,target_stock_pct:num:70
|
||||
\\type::config,horizon:num:0
|
||||
\\type::config,horizon:num:30,retirement_target:num:95
|
||||
\\type::config,horizon_age:num:90,retirement_target:num:99
|
||||
\\type::config,spending_change:num:20
|
||||
\\type::event,name::Pension,amount:num:100
|
||||
\\
|
||||
;
|
||||
const plain = parseProjectionsConfig(data);
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var sink: srf_lint.Sink = .{ .allocator = arena_state.allocator(), .findings = .empty };
|
||||
const linted = try parseWith(data, .{ .sink = &sink });
|
||||
|
||||
try std.testing.expect(sink.findings.items.len > 0);
|
||||
try std.testing.expectEqual(plain.target_stock_pct, linted.target_stock_pct);
|
||||
try std.testing.expectEqual(plain.return_cap, linted.return_cap);
|
||||
try std.testing.expectEqual(plain.horizon_count, linted.horizon_count);
|
||||
try std.testing.expectEqualSlices(u16, &plain.horizons, &linted.horizons);
|
||||
try std.testing.expectEqualSlices(u8, &plain.horizon_targets, &linted.horizon_targets);
|
||||
try std.testing.expectEqualSlices(u8, &plain.horizon_age_targets, &linted.horizon_age_targets);
|
||||
try std.testing.expectEqual(plain.spending_real_change, linted.spending_real_change);
|
||||
try std.testing.expectEqual(plain.event_count, linted.event_count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ pub fn compareAccounts(
|
|||
|
||||
if (bp.is_cash) {
|
||||
saw_cash_row = true;
|
||||
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
|
||||
pf_shares = portfolio.cashForAccount(as_of, portfolio_acct_name.?);
|
||||
pf_value = pf_shares;
|
||||
} else {
|
||||
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
|
||||
|
|
@ -554,7 +554,7 @@ pub fn compareAccounts(
|
|||
// the balance is zero. Absence is strong evidence, not proof, and
|
||||
// `audit` exists to surface things for a human to decide.
|
||||
if (!saw_cash_row) {
|
||||
const pf_cash = portfolio.cashForAccount(pa);
|
||||
const pf_cash = portfolio.cashForAccount(as_of, pa);
|
||||
if (@abs(pf_cash) > cash_tolerance) {
|
||||
portfolio_total += pf_cash;
|
||||
has_discrepancies = true;
|
||||
|
|
@ -1719,3 +1719,38 @@ test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
|
|||
defer allocator.free(sugg);
|
||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||
}
|
||||
|
||||
test "compareAccounts: a cash lot closed in the portfolio is not flagged portfolio-only" {
|
||||
// The companion to "cash the broker no longer reports": once the
|
||||
// user records the close, the lot is gone as of today. It used to
|
||||
// still be counted by `cashForAccount`, so closing it didn't clear
|
||||
// the flag - only deleting the line did.
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL", .close_date = Date.fromYmd(2026, 7, 1) },
|
||||
.{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var brokerage = [_]BrokeragePosition{
|
||||
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
|
||||
};
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("FDSCX", 50.67);
|
||||
|
||||
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
for (results[0].comparisons) |cmp| {
|
||||
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ pub fn compareSchwabSummary(
|
|||
var expectation: common.AccountValueExpectation = .{};
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_cash = portfolio.cashForAccount(as_of, pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
expectation = common.accountValueExpectation(portfolio, as_of, pa);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,6 +487,14 @@ pub fn portfolioSummary(
|
|||
var total_realized: f64 = 0;
|
||||
|
||||
for (positions) |pos| {
|
||||
// Realized P&L first, before either skip below. It is already
|
||||
// known from the closed lots and needs no price - and the
|
||||
// positions it matters most for are the ones those skips drop:
|
||||
// a fully sold symbol (`shares == 0`) exists here ONLY to carry
|
||||
// its realized gain, and used to be discarded along with it.
|
||||
// Same for a held symbol with no current price.
|
||||
total_realized += pos.realized_gain_loss;
|
||||
|
||||
if (pos.shares <= 0) continue;
|
||||
const raw_price = prices.get(pos.symbol) orelse continue;
|
||||
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
|
||||
|
|
@ -494,7 +502,6 @@ pub fn portfolioSummary(
|
|||
const mv = pos.marketValue(raw_price, is_manual);
|
||||
total_value += mv;
|
||||
total_cost += pos.total_cost;
|
||||
total_realized += pos.realized_gain_loss;
|
||||
|
||||
try allocs.append(allocator, .{
|
||||
.symbol = pos.symbol,
|
||||
|
|
@ -2614,3 +2621,30 @@ test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "portfolioSummary: realized P&L includes fully sold and unpriced symbols" {
|
||||
// Regression: the `shares <= 0` and no-price skips ran BEFORE the
|
||||
// realized sum, so a symbol you had sold entirely - the most common
|
||||
// source of realized gains - never reached "Realized P&L".
|
||||
const alloc = std.testing.allocator;
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("HELD", 20.0);
|
||||
|
||||
const positions = [_]portfolio_mod.Position{
|
||||
// Held and priced, with a partial sale behind it.
|
||||
.{ .symbol = "HELD", .shares = 10, .avg_cost = 10, .total_cost = 100, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 30 },
|
||||
// Fully sold: exists only to carry its realized gain.
|
||||
.{ .symbol = "SOLD", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 2, .realized_gain_loss = 500 },
|
||||
// Held but no current price; its realized loss still counts.
|
||||
.{ .symbol = "NOPRICE", .shares = 5, .avg_cost = 8, .total_cost = 40, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = -20 },
|
||||
};
|
||||
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
|
||||
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
|
||||
defer summary.deinit(alloc);
|
||||
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 510), summary.realized_gain_loss, 0.001);
|
||||
// The skips still apply to everything else: only HELD is valued.
|
||||
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.total_value, 0.001);
|
||||
}
|
||||
|
|
|
|||
92
src/cache/store.zig
vendored
92
src/cache/store.zig
vendored
|
|
@ -25,6 +25,7 @@ const Edgar = @import("../providers/Edgar.zig");
|
|||
// require millisecond precision to avoid collisions, which a
|
||||
// caller-provided second-resolution `now_s` couldn't give us.
|
||||
const Lot = @import("../models/portfolio.zig").Lot;
|
||||
const ParsedLot = @import("../models/portfolio.zig").ParsedLot;
|
||||
const LotType = @import("../models/portfolio.zig").LotType;
|
||||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||
const OptionsChain = @import("../models/option.zig").OptionsChain;
|
||||
|
|
@ -2303,7 +2304,11 @@ pub fn deserializePortfolioDiag(
|
|||
// `user_edited` coercion: see `srf_opts.zig` for why hand-edited
|
||||
// files get different options from cache files. The `catch`
|
||||
// below still handles genuinely unparseable values.
|
||||
var lot = fields.to(Lot, srf_opts.user_edited) catch |err| {
|
||||
//
|
||||
// Parsed as `ParsedLot`, not `Lot`, so a cash or watch lot may
|
||||
// omit the fields its type doesn't need; `Lot.fromParsed` then
|
||||
// applies the per-type rule and names what's missing.
|
||||
const parsed = fields.to(ParsedLot, srf_opts.user_edited) catch |err| {
|
||||
if (diags) |d| {
|
||||
try appendParseDiag(allocator, d, data, line, @errorName(err));
|
||||
} else if (!builtin.is_test) {
|
||||
|
|
@ -2315,6 +2320,22 @@ pub fn deserializePortfolioDiag(
|
|||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
var lot = switch (Lot.fromParsed(parsed)) {
|
||||
.lot => |l| l,
|
||||
.missing => |field| {
|
||||
// `appendParseDiag` formats `msg` into its own string,
|
||||
// so this one is freed right after.
|
||||
const msg = try std.fmt.allocPrint(allocator, "{s} is required for {s} lots", .{ field, @tagName(parsed.security_type) });
|
||||
defer allocator.free(msg);
|
||||
if (diags) |d| {
|
||||
try appendParseDiag(allocator, d, data, line, msg);
|
||||
} else if (!builtin.is_test) {
|
||||
std.log.warn("portfolio: record at line {d}: {s}; skipping", .{ line, msg });
|
||||
}
|
||||
skipped += 1;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
// Dupe owned strings before iterator.deinit() frees the backing buffer
|
||||
lot.symbol = try allocator.dupe(u8, lot.symbol);
|
||||
|
|
@ -2740,11 +2761,15 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
|
|||
|
||||
var s = Store.init(io, allocator, dir_path);
|
||||
|
||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
// Bracket the write: `writeSupplement` reads the clock itself, so
|
||||
// an instant captured only before it can be a second behind the
|
||||
// one it stamps, which flakes the upper bound below.
|
||||
const before_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
var incoming = [_]Dividend{
|
||||
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
||||
};
|
||||
s.writeSupplement(Dividend, "TEST", incoming[0..], "tiingo");
|
||||
const after_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
|
||||
const path = try std.fs.path.join(allocator, &.{ dir_path, "TEST", "dividends.srf" });
|
||||
defer allocator.free(path);
|
||||
|
|
@ -2760,8 +2785,8 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" {
|
|||
const spec = DataType.dividends.ttl();
|
||||
const jitter = @divFloor(spec.seconds * @as(i64, spec.jitter_pct), 100);
|
||||
const new_expires = it.expires orelse return error.ExpiresMissing;
|
||||
try std.testing.expect(new_expires - now_s >= spec.seconds - jitter);
|
||||
try std.testing.expect(new_expires - now_s <= spec.seconds + jitter);
|
||||
try std.testing.expect(new_expires - before_s >= spec.seconds - jitter);
|
||||
try std.testing.expect(new_expires - after_s <= spec.seconds + jitter);
|
||||
}
|
||||
|
||||
test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon)" {
|
||||
|
|
@ -4724,3 +4749,62 @@ test "diskStats and cacheKeys agree on the symbol count" {
|
|||
// still on the disk, so the top-level file still counts toward `files`.
|
||||
try std.testing.expectEqual(@as(usize, 1), ds.files);
|
||||
}
|
||||
|
||||
test "deserializePortfolioDiag: cash and watch lots may omit what their type doesn't need" {
|
||||
// The shapes the reference docs show. Before `ParsedLot`, both were
|
||||
// dropped with FieldNotFoundOnFieldWithoutDefaultValue.
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\security_type::cash,shares:num:48000,account::Sample Brokerage
|
||||
\\security_type::watch,symbol::NVDA
|
||||
\\
|
||||
;
|
||||
var diags: ParseDiagnostics = .empty;
|
||||
defer {
|
||||
for (diags.items) |d| allocator.free(d);
|
||||
diags.deinit(allocator);
|
||||
}
|
||||
var pf = try deserializePortfolioDiag(allocator, data, &diags);
|
||||
defer pf.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), diags.items.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), pf.lots.len);
|
||||
try std.testing.expectEqual(LotType.cash, pf.lots[0].security_type);
|
||||
try std.testing.expectEqual(@as(f64, 48000), pf.lots[0].shares);
|
||||
try std.testing.expectEqual(@as(f64, 1.0), pf.lots[0].open_price);
|
||||
try std.testing.expectEqual(LotType.watch, pf.lots[1].security_type);
|
||||
try std.testing.expectEqualStrings("NVDA", pf.lots[1].symbol);
|
||||
}
|
||||
|
||||
test "deserializePortfolioDiag: a stock lot missing open_price is still rejected, by name" {
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-01-15
|
||||
\\
|
||||
;
|
||||
var diags: ParseDiagnostics = .empty;
|
||||
defer {
|
||||
for (diags.items) |d| allocator.free(d);
|
||||
diags.deinit(allocator);
|
||||
}
|
||||
var pf = try deserializePortfolioDiag(allocator, data, &diags);
|
||||
defer pf.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), pf.lots.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), diags.items.len);
|
||||
try std.testing.expect(std.mem.indexOf(u8, diags.items[0], "line 2: open_price is required for stock lots") != null);
|
||||
}
|
||||
|
||||
test "serializePortfolio: output is unchanged for a cash lot the reader filled in" {
|
||||
// Only the reader was relaxed. A filled-in cash lot writes back with
|
||||
// its fields spelled out, exactly as `zfin import` always wrote it.
|
||||
const allocator = std.testing.allocator;
|
||||
var pf = try deserializePortfolio(allocator, "#!srfv1\nsecurity_type::cash,shares:num:100,account::Sample IRA\n");
|
||||
defer pf.deinit();
|
||||
const out = try serializePortfolio(allocator, pf.lots);
|
||||
defer allocator.free(out);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "open_date::1970-01-01") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "open_price:num:1") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ const contributions = @import("../contributions.zig");
|
|||
const Money = @import("../../Money.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
const classification = @import("../../models/classification.zig");
|
||||
const transaction_log = @import("../../models/transaction_log.zig");
|
||||
const projections = @import("../../analytics/projections.zig");
|
||||
const srf_lint = @import("../../srf_lint.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const srf = @import("srf");
|
||||
const git = @import("../../git.zig");
|
||||
|
|
@ -657,6 +661,15 @@ fn printLargeLotWarning(
|
|||
var date_buf: [10]u8 = undefined;
|
||||
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
|
||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
|
||||
// `Date.epoch` means "no date": `zfin import` writes it, a cash lot
|
||||
// may omit `open_date` altogether, and a `cash_contribution` (a
|
||||
// deposit into an existing cash lot) has none. Printing 1970-01-01 as
|
||||
// the day of the contribution would be a lie, and pasting it into
|
||||
// `transaction_log.srf` would record one - so ask for the date
|
||||
// instead. `dest_lot::` keeps the literal date: it names the lot,
|
||||
// must parse, and the transfer matcher ignores its date.
|
||||
const undated = lot.open_date.eql(Date.epoch);
|
||||
const transfer_date: []const u8 = if (undated) "<DATE>" else date_str;
|
||||
const kind_label: []const u8 = switch (lot.security_type) {
|
||||
.stock => "STOCK",
|
||||
.cash => "CASH",
|
||||
|
|
@ -671,7 +684,7 @@ fn printLargeLotWarning(
|
|||
.{ lot.account, kind_label, sym_for_display },
|
||||
);
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
|
||||
try out.print(" on {s}\n", .{date_str});
|
||||
if (undated) try out.writeAll(" (date unknown)\n") else try out.print(" on {s}\n", .{date_str});
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
|
||||
|
|
@ -688,7 +701,7 @@ fn printLargeLotWarning(
|
|||
color,
|
||||
cli.CLR_MUTED,
|
||||
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
|
||||
.{ date_str, lot.value, lot.account },
|
||||
.{ transfer_date, lot.value, lot.account },
|
||||
);
|
||||
} else {
|
||||
try cli.printFg(
|
||||
|
|
@ -696,7 +709,7 @@ fn printLargeLotWarning(
|
|||
color,
|
||||
cli.CLR_MUTED,
|
||||
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
|
||||
.{ date_str, lot.value, lot.account, lot.symbol, date_str },
|
||||
.{ transfer_date, lot.value, lot.account, lot.symbol, date_str },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1231,7 +1244,7 @@ pub fn runHygieneCheck(
|
|||
// (not in a git repo). Threshold is per-account: an account's
|
||||
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
||||
// filter's built-in default applies.
|
||||
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
|
||||
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
|
||||
var found_mut = found;
|
||||
defer found_mut.deinit();
|
||||
|
||||
|
|
@ -1265,9 +1278,112 @@ pub fn runHygieneCheck(
|
|||
}
|
||||
} else |_| {}
|
||||
|
||||
// ── Section 8: Config file problems ──
|
||||
//
|
||||
// SRF discards a record key that names no field of the target
|
||||
// struct, silently: `price_dat::2024-01-01` is indistinguishable
|
||||
// from having omitted the price date. 19 of `Lot`'s 22 fields have
|
||||
// defaults, so most typos here change behavior with no output at
|
||||
// all. Rules live with the models (`srf_schema`); `srf_lint` walks
|
||||
// the records. `zfin doctor` reports the same findings per-file with
|
||||
// a reference-page pointer; this is the routine-use summary.
|
||||
//
|
||||
// Silent when clean.
|
||||
//
|
||||
// `as_of` is passed as the lint's `today`: its date rules ("a
|
||||
// close_date in the future") only make sense against the real
|
||||
// current day, and audit sets `as_of = ctx.today` unconditionally
|
||||
// (`commands/audit.zig`) - there is no `--as-of` flag to back-date it.
|
||||
try printFieldNameSection(io, allocator, portfolio_paths, portfolio_path, as_of, color, out);
|
||||
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Section 8: field names that no model explains, plus each model's
|
||||
/// `semanticCheck` rules (e.g. `Lot`'s impossible lifecycle dates).
|
||||
///
|
||||
/// Covers every `portfolio*.srf` in the glob plus the portfolio's
|
||||
/// siblings. `keys.srf` / `theme.srf` are deliberately EXCLUDED - they
|
||||
/// live under `~/.config/zfin` and configure the TUI, not the
|
||||
/// portfolio, so they are `doctor`'s business. This section is
|
||||
/// "portfolio hygiene".
|
||||
fn printFieldNameSection(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio_paths: []const []const u8,
|
||||
anchor: []const u8,
|
||||
today: Date,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
var header_shown = false;
|
||||
|
||||
for (portfolio_paths) |p| {
|
||||
try lintOne(io, allocator, portfolio_mod.srf_schema, p, today, color, &header_shown, out);
|
||||
}
|
||||
const siblings = .{
|
||||
.{ analysis.srf_schema, "accounts.srf" },
|
||||
.{ classification.srf_schema, "metadata.srf" },
|
||||
.{ transaction_log.srf_schema, "transaction_log.srf" },
|
||||
.{ projections.srf_schema, "projections.srf" },
|
||||
.{ cli.srf_schema, "watchlist.srf" },
|
||||
};
|
||||
inline for (siblings) |pair| {
|
||||
if (cli.siblingPath(allocator, anchor, pair[1])) |path| {
|
||||
defer allocator.free(path);
|
||||
try lintOne(io, allocator, pair[0], path, today, color, &header_shown, out);
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lint one file, emitting the shared section header on first finding so
|
||||
/// a clean run prints nothing at all.
|
||||
fn lintOne(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
comptime S: type,
|
||||
path: []const u8,
|
||||
today: Date,
|
||||
color: bool,
|
||||
header_shown: *bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024 * 1024)) catch return;
|
||||
defer allocator.free(bytes);
|
||||
|
||||
var result = srf_lint.check(allocator, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
|
||||
defer result.deinit();
|
||||
// Model-owned semantic rules (a second, typed pass - SRF's
|
||||
// iterators are single-pass, so `to()` cannot share the raw walk).
|
||||
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
|
||||
result.sort();
|
||||
if (result.findings.len == 0) return;
|
||||
|
||||
if (!header_shown.*) {
|
||||
try out.print("\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " Config file problems\n", .{});
|
||||
header_shown.* = true;
|
||||
}
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{std.fs.path.basename(path)});
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
for (result.findings) |f| {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " line {d}: {s}\n", .{ f.first_line, f.describe(&buf) });
|
||||
}
|
||||
if (result.truncated) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " (more suppressed; fix these first)\n", .{});
|
||||
}
|
||||
// The authoritative field list, derived from the struct - so unlike
|
||||
// a doc page it cannot be out of date. Printed whenever there is
|
||||
// any NAME finding, which is what lets the suggestion matcher stay
|
||||
// conservative: even with no guess to offer, the user gets the set.
|
||||
// Skipped for lifecycle-only reports, where it would be noise.
|
||||
if (result.hasNameFindings()) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " valid fields:\n", .{});
|
||||
try srf_lint.writeValidNames(out, result.shape, 8, 76);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "accumulatePresent: unions account numbers across calls" {
|
||||
|
|
@ -2202,6 +2318,68 @@ test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opt
|
|||
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: a large deposit on a cash_is_contribution account doesn't blank Large new lots" {
|
||||
// End-to-end regression for the `MissingOpenDate` bug: the deposit
|
||||
// becomes a `cash_contribution` with no open_date, which used to
|
||||
// error out of `collectUnmatchedLargeLots` and suppress the whole
|
||||
// section - taking the unrelated VTI lot down with it.
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
if (!test_git.available(allocator)) return;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
// Committed state: $10k cash on an account whose cash deposits are
|
||||
// contributions.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "portfolio.srf",
|
||||
.data = "#!srfv1\nsecurity_type::cash,shares:num:10000,open_date::2025-01-01,open_price:num:1,account::Sample IRA\n",
|
||||
});
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "accounts.srf",
|
||||
.data = "#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
|
||||
});
|
||||
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
|
||||
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
|
||||
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
|
||||
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
|
||||
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "accounts.srf" });
|
||||
try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" });
|
||||
|
||||
// Working copy: a $50k deposit, plus a $25k new stock lot.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "portfolio.srf",
|
||||
.data =
|
||||
\\#!srfv1
|
||||
\\security_type::cash,shares:num:60000,open_date::2025-01-01,open_price:num:1,account::Sample IRA
|
||||
\\symbol::VTI,shares:num:100,open_date::2026-05-10,open_price:num:250,account::Sample IRA
|
||||
\\
|
||||
,
|
||||
});
|
||||
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
var env = try std.testing.environ.createMap(allocator);
|
||||
defer env.deinit();
|
||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(pf_path);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
defer aw.deinit();
|
||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 5, 11), 1_778_500_000, false, .never, &aw.writer);
|
||||
|
||||
const out = aw.written();
|
||||
const start = std.mem.indexOf(u8, out, "Large new lots") orelse return error.LargeLotSectionMissing;
|
||||
const sec = out[start..];
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "VTI") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "+$50,000.00 (date unknown)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "transfer::<DATE>,type::cash,amount:num:50000.00") != null);
|
||||
}
|
||||
|
||||
/// Format a two-account portfolio.srf for the git-history test. Only
|
||||
/// `shares` varies between revisions, which is enough for
|
||||
/// findModifiedAccounts to flag the account.
|
||||
|
|
@ -2284,3 +2462,202 @@ test "findLastUpdateTimestamps: resolves an account changed in a non-newest comm
|
|||
try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
|
||||
try std.testing.expect(ira_ts < roth_ts);
|
||||
}
|
||||
|
||||
// ── Section 8: unrecognized field names ──
|
||||
|
||||
/// Shared harness for the Section 8 integration tests. Writes
|
||||
/// `portfolio.srf` + `accounts.srf` into a tmpdir, runs the full
|
||||
/// hygiene check, and returns the whole report for the caller to slice.
|
||||
///
|
||||
/// Caller owns the returned bytes.
|
||||
fn runSection8(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio_srf: []const u8,
|
||||
accounts_srf: []const u8,
|
||||
) ![]u8 {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = portfolio_srf });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = accounts_srf });
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
// No API keys / no server -> hermetic.
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
|
||||
var env = try std.testing.environ.createMap(allocator);
|
||||
defer env.deinit();
|
||||
|
||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(pf_path);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);
|
||||
return allocator.dupe(u8, aw.written());
|
||||
}
|
||||
|
||||
const section8_header = "Config file problems";
|
||||
|
||||
test "runHygieneCheck: Section 8 is silent for a clean portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample Brokerage
|
||||
\\
|
||||
,
|
||||
"#!srfv1\naccount::Sample Brokerage,tax_type::taxable\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio hygiene") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 reports a typo in portfolio.srf with a suggestion" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,price_dat::2025-01-01
|
||||
\\
|
||||
,
|
||||
"#!srfv1\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
|
||||
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
||||
const sec = out[start..];
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "portfolio.srf") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "price_dat") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'price_date'?") != null);
|
||||
// The valid-name footer is what makes the conservative matcher
|
||||
// sufficient, so it must actually be emitted.
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "valid fields:") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "option_type") != null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 covers accounts.srf, not just the portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample IRA
|
||||
\\
|
||||
,
|
||||
"#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_rothh:num:20\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
|
||||
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
||||
const sec = out[start..];
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "accounts.srf") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'tax_mix_roth'?") != null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 flags a field that does not apply to the security_type" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,rate:num:4.25
|
||||
\\
|
||||
,
|
||||
"#!srfv1\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
|
||||
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
||||
const sec = out[start..];
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "'rate' ignored for security_type stock") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "read only for cd") != null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 flags a future close_date" {
|
||||
// `runSection8` pins as_of to 2026-01-01, which the lint uses as
|
||||
// `today`. A close in 2062 is the typo'd-year case.
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
|
||||
\\
|
||||
,
|
||||
"#!srfv1\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
|
||||
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
||||
const sec = out[start..];
|
||||
try std.testing.expect(std.mem.indexOf(u8, sec, "line 2: VTI close_date 2062-03-14 is in the future") != null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 stays silent for a correctly closed lot" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2025-03-14,close_price:num:300
|
||||
\\
|
||||
,
|
||||
"#!srfv1\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: Section 8 omits the valid-field list for date-only findings" {
|
||||
const allocator = std.testing.allocator;
|
||||
const out = try runSection8(
|
||||
allocator,
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
|
||||
\\
|
||||
,
|
||||
"#!srfv1\n",
|
||||
);
|
||||
defer allocator.free(out);
|
||||
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
|
||||
try std.testing.expect(std.mem.indexOf(u8, out[start..], "valid fields:") == null);
|
||||
}
|
||||
|
||||
test "printLargeLotWarning: an undated lot asks for the date instead of printing 1970-01-01" {
|
||||
// `Date.epoch` is what import writes and what a cash lot that omits
|
||||
// `open_date` gets. The suggested transfer record must not carry it.
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buf);
|
||||
try printLargeLotWarning(&writer, .{
|
||||
.account = "Sample Brokerage",
|
||||
.symbol = "",
|
||||
.security_type = .cash,
|
||||
.value = 25_000.0,
|
||||
.open_date = Date.epoch,
|
||||
}, false);
|
||||
const output = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "1970") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "(date unknown)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,type::cash,amount:num:25000.00") != null);
|
||||
}
|
||||
|
||||
test "printLargeLotWarning: an undated stock lot keeps the literal date in dest_lot" {
|
||||
// dest_lot names the lot and must parse as SYMBOL@YYYY-MM-DD; only
|
||||
// the transfer's own date is a placeholder.
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buf);
|
||||
try printLargeLotWarning(&writer, .{
|
||||
.account = "Sample IRA",
|
||||
.symbol = "VTI",
|
||||
.security_type = .stock,
|
||||
.value = 30_000.0,
|
||||
.open_date = Date.epoch,
|
||||
}, false);
|
||||
const output = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1127,14 +1127,27 @@ pub fn resolveAsOfOrExplain(
|
|||
|
||||
// ── Watchlist loading ────────────────────────────────────────
|
||||
|
||||
/// One record from `watchlist.srf`.
|
||||
///
|
||||
/// Named (rather than declared inline in `loadWatchlist`) so
|
||||
/// `srf_schema` below can derive the valid-field set from it - an
|
||||
/// anonymous struct would leave the lint with a hand-copied name list
|
||||
/// that could drift from the parser.
|
||||
pub const WatchEntry = struct { symbol: []const u8 };
|
||||
|
||||
/// Schema contract for `watchlist.srf`. See `srf_lint.validateSchema`.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = WatchEntry;
|
||||
pub const file_label: []const u8 = "watchlist.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/watchlist-srf.md";
|
||||
};
|
||||
|
||||
/// Load a watchlist SRF file containing symbol records.
|
||||
/// Returns owned symbol strings. Returns null if file missing or empty.
|
||||
pub fn loadWatchlist(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null;
|
||||
defer allocator.free(file_data);
|
||||
|
||||
const WatchEntry = struct { symbol: []const u8 };
|
||||
|
||||
var reader = std.Io.Reader.fixed(file_data);
|
||||
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null;
|
||||
defer it.deinit();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,11 +32,15 @@ const fmt = cli.fmt;
|
|||
|
||||
const Config = zfin.Config;
|
||||
const Lot = @import("../models/portfolio.zig").Lot;
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const srf_lint = @import("../srf_lint.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
const cache = @import("../cache/store.zig");
|
||||
const freshness = @import("../cache/freshness.zig");
|
||||
const classification = @import("../models/classification.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const Journal = @import("../data/Journal.zig");
|
||||
const transaction_log = @import("../models/transaction_log.zig");
|
||||
const imported_values = @import("../data/imported_values.zig");
|
||||
const history = @import("../history.zig");
|
||||
|
|
@ -514,6 +518,66 @@ fn checkSrfFile(
|
|||
}
|
||||
}
|
||||
|
||||
/// Lint one user-authored SRF file's FIELD NAMES, appending one `.warn`
|
||||
/// row per rolled-up finding plus a pointer to the reference page.
|
||||
///
|
||||
/// Distinct from the `checkSrfFile` row above it, which answers "does
|
||||
/// this file parse". A file can parse perfectly and still be wrong:
|
||||
/// SRF discards a key that names no field of the target struct without
|
||||
/// any error, so `price_dat::2024-01-01` is indistinguishable from
|
||||
/// having omitted the price date entirely. See `src/srf_lint.zig`.
|
||||
///
|
||||
/// Silent when the file is absent, unreadable, or clean - its presence
|
||||
/// and parseability are already reported by the row above, and
|
||||
/// repeating that here would double every message.
|
||||
///
|
||||
/// `.warn`, never `.fail`: a typo is a nudge, and `doctor`'s exit code
|
||||
/// is reserved for a file that exists but cannot be parsed at all.
|
||||
fn appendFieldNameChecks(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
checks: *std.ArrayList(Check),
|
||||
comptime S: type,
|
||||
path: []const u8,
|
||||
today: Date,
|
||||
) !void {
|
||||
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch return;
|
||||
var result = srf_lint.check(arena, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
|
||||
defer result.deinit();
|
||||
// Model-owned semantic rules (a second, typed pass - SRF's
|
||||
// iterators are single-pass, so `to()` cannot share the raw walk).
|
||||
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
|
||||
result.sort();
|
||||
if (result.findings.len == 0) return;
|
||||
|
||||
const base = std.fs.path.basename(path);
|
||||
for (result.findings) |f| {
|
||||
// `describe` copies into arena memory, which outlives
|
||||
// `result.deinit()` below; `f.detail` would not.
|
||||
const buf = try arena.alloc(u8, srf_lint.Finding.describe_max);
|
||||
try checks.append(arena, .{
|
||||
.status = .warn,
|
||||
.label = try std.fmt.allocPrint(arena, " {s}:{d}", .{ base, f.first_line }),
|
||||
.detail = f.describe(buf),
|
||||
});
|
||||
}
|
||||
if (result.truncated) {
|
||||
try checks.append(arena, .{
|
||||
.status = .warn,
|
||||
.label = try std.fmt.allocPrint(arena, " {s}", .{base}),
|
||||
.detail = "more findings suppressed; fix these first",
|
||||
});
|
||||
}
|
||||
// The reference page is guaranteed complete - `srf_lint`'s doc-sync
|
||||
// test fails the build if a model field is missing from it - so
|
||||
// pointing there is sound advice rather than a shrug.
|
||||
try checks.append(arena, .{
|
||||
.status = .info,
|
||||
.label = " field reference",
|
||||
.detail = try std.fmt.allocPrint(arena, "docs/{s}", .{S.doc_path}),
|
||||
});
|
||||
}
|
||||
|
||||
/// Join a sibling filename onto the anchor portfolio's directory.
|
||||
// ── run ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -556,6 +620,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
for (pf.paths) |rp| {
|
||||
const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
|
||||
try checks.append(arena, c);
|
||||
try appendFieldNameChecks(io, arena, &checks, portfolio_mod.srf_schema, rp.path, ctx.today);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +629,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
{
|
||||
const r = checkSrfFile(io, arena, "accounts.srf", try cli.siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
|
||||
try checks.append(arena, r);
|
||||
try appendFieldNameChecks(io, arena, &checks, analysis.srf_schema, try cli.siblingPath(arena, a, "accounts.srf"), ctx.today);
|
||||
if (r.status == .ok) {
|
||||
const path = try cli.siblingPath(arena, a, "accounts.srf");
|
||||
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
|
||||
|
|
@ -575,6 +641,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
{
|
||||
const r = checkSrfFile(io, arena, "metadata.srf", try cli.siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
|
||||
try checks.append(arena, r);
|
||||
try appendFieldNameChecks(io, arena, &checks, classification.srf_schema, try cli.siblingPath(arena, a, "metadata.srf"), ctx.today);
|
||||
if (r.status == .ok) {
|
||||
const path = try cli.siblingPath(arena, a, "metadata.srf");
|
||||
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
|
||||
|
|
@ -586,6 +653,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
{
|
||||
const r = checkSrfFile(io, arena, "transaction_log.srf", try cli.siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
|
||||
try checks.append(arena, r);
|
||||
try appendFieldNameChecks(io, arena, &checks, transaction_log.srf_schema, try cli.siblingPath(arena, a, "transaction_log.srf"), ctx.today);
|
||||
if (r.status == .ok) {
|
||||
const path = try cli.siblingPath(arena, a, "transaction_log.srf");
|
||||
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
|
||||
|
|
@ -594,6 +662,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
}
|
||||
}
|
||||
try checks.append(arena, checkSrfFile(io, arena, "projections.srf", try cli.siblingPath(arena, a, "projections.srf"), .optional, validateSrf));
|
||||
try appendFieldNameChecks(io, arena, &checks, projections.srf_schema, try cli.siblingPath(arena, a, "projections.srf"), ctx.today);
|
||||
// `watchlist.srf` has no parse-check row of its own -
|
||||
// `loadWatchlist` degrades to null - but its one field is
|
||||
// as typo-able as any other.
|
||||
try appendFieldNameChecks(io, arena, &checks, cli.srf_schema, try cli.siblingPath(arena, a, "watchlist.srf"), ctx.today);
|
||||
try appendFieldNameChecks(io, arena, &checks, Journal.srf_schema, try cli.siblingPath(arena, a, "acknowledgments.srf"), ctx.today);
|
||||
// imported_values.srf and the snapshots both live under
|
||||
// <portfolio_dir>/history/, NOT directly beside the
|
||||
// portfolio file.
|
||||
|
|
@ -609,6 +683,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
// keys.srf / theme.srf live under $HOME/.config/zfin.
|
||||
try checks.append(arena, checkUserConfigFiles(io, arena, config, .keys));
|
||||
try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme));
|
||||
if (userConfigPath(arena, config, "keys.srf")) |p| {
|
||||
try appendFieldNameChecks(io, arena, &checks, keybinds.srf_schema, p, ctx.today);
|
||||
}
|
||||
if (userConfigPath(arena, config, "theme.srf")) |p| {
|
||||
try appendFieldNameChecks(io, arena, &checks, theme.srf_schema, p, ctx.today);
|
||||
}
|
||||
|
||||
try sections.append(arena, .{ .title = "Files", .checks = checks.items });
|
||||
}
|
||||
|
|
@ -729,10 +809,28 @@ fn checkPortfolioFile(io: std.Io, arena: std.mem.Allocator, path: []const u8, al
|
|||
if (err == error.FileNotFound) return .{ .status = .warn, .label = "portfolio.srf", .detail = "not found" };
|
||||
return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
|
||||
};
|
||||
const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
|
||||
// Ask for diagnostics, not just the lot count. A record that fails
|
||||
// to coerce is SKIPPED, not fatal, so the plain
|
||||
// `deserializePortfolio` reports `.ok` with a quietly-smaller
|
||||
// number - and nobody has a baseline for "how many lots should
|
||||
// there be". Reporting the skip count is the only way a dropped
|
||||
// record is visible at all.
|
||||
var diags: cache.ParseDiagnostics = .empty;
|
||||
const pf = cache.deserializePortfolioDiag(arena, bytes, &diags) catch |err| {
|
||||
return .{ .status = .fail, .label = path, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
|
||||
};
|
||||
try all_lots.appendSlice(arena, pf.lots);
|
||||
if (diags.items.len > 0) {
|
||||
return .{
|
||||
.status = .warn,
|
||||
.label = path,
|
||||
.detail = std.fmt.allocPrint(
|
||||
arena,
|
||||
"{d} lots; {d} record(s) SKIPPED - {s}",
|
||||
.{ pf.lots.len, diags.items.len, diags.items[0] },
|
||||
) catch "records skipped",
|
||||
};
|
||||
}
|
||||
return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
|
||||
}
|
||||
|
||||
|
|
@ -807,6 +905,15 @@ const UserConfigKind = enum { keys, theme };
|
|||
|
||||
/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
|
||||
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader.
|
||||
/// `$HOME/.config/zfin/<filename>`, or null when HOME is unset.
|
||||
/// Mirrors the path `checkUserConfigFiles` builds, so the field-name
|
||||
/// lint reads the same file the parse-check reported on.
|
||||
fn userConfigPath(arena: std.mem.Allocator, config: Config, filename: []const u8) ?[]const u8 {
|
||||
const home = if (config.environ_map) |em| em.get("HOME") else null;
|
||||
if (home == null) return null;
|
||||
return std.fs.path.join(arena, &.{ home.?, ".config", "zfin", filename }) catch null;
|
||||
}
|
||||
|
||||
fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
|
||||
const filename = switch (kind) {
|
||||
.keys => "keys.srf",
|
||||
|
|
@ -1617,3 +1724,182 @@ test "checkCandleFreshness: an untracked laggard does not warn" {
|
|||
const c = try checkCandleFreshness(arena, &store, &tracked, &.{}, now_s);
|
||||
try testing.expectEqual(Status.ok, c.status);
|
||||
}
|
||||
|
||||
// ── appendFieldNameChecks ──
|
||||
|
||||
/// Fixed `today` so the date rules' verdicts don't drift with the clock.
|
||||
const fixed_today = Date.fromYmd(2026, 1, 1);
|
||||
|
||||
/// Write `data` to a tmpdir and collect the field-name checks it
|
||||
/// produces for schema `S`. Caller owns nothing - everything lives in
|
||||
/// the arena the caller passes.
|
||||
fn fieldChecksFor(
|
||||
arena: std.mem.Allocator,
|
||||
comptime S: type,
|
||||
filename: []const u8,
|
||||
data: []const u8,
|
||||
) !std.ArrayList(Check) {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = filename, .data = data });
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const path = try std.fs.path.join(arena, &.{ path_buf[0..dir_len], filename });
|
||||
|
||||
var checks: std.ArrayList(Check) = .empty;
|
||||
try appendFieldNameChecks(io, arena, &checks, S, path, fixed_today);
|
||||
return checks;
|
||||
}
|
||||
|
||||
test "appendFieldNameChecks: clean file adds nothing" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const checks = try fieldChecksFor(
|
||||
arena,
|
||||
portfolio_mod.srf_schema,
|
||||
"portfolio.srf",
|
||||
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1\n",
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
|
||||
}
|
||||
|
||||
test "appendFieldNameChecks: missing file adds nothing" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var checks: std.ArrayList(Check) = .empty;
|
||||
try appendFieldNameChecks(std.testing.io, arena, &checks, portfolio_mod.srf_schema, "/nonexistent/portfolio.srf", fixed_today);
|
||||
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
|
||||
}
|
||||
|
||||
test "appendFieldNameChecks: a typo warns and points at the reference page" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const checks = try fieldChecksFor(
|
||||
arena,
|
||||
portfolio_mod.srf_schema,
|
||||
"portfolio.srf",
|
||||
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01\n",
|
||||
);
|
||||
// One finding row plus the reference-page pointer.
|
||||
try std.testing.expectEqual(@as(usize, 2), checks.items.len);
|
||||
|
||||
try std.testing.expectEqual(Status.warn, checks.items[0].status);
|
||||
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].label, "portfolio.srf:2") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].detail, "did you mean 'price_date'?") != null);
|
||||
|
||||
// `.info`, not `.warn` - the pointer is not itself a problem.
|
||||
try std.testing.expectEqual(Status.info, checks.items[1].status);
|
||||
try std.testing.expect(std.mem.indexOf(u8, checks.items[1].detail, "portfolio-srf.md") != null);
|
||||
}
|
||||
|
||||
test "appendFieldNameChecks: never returns .fail, so a typo cannot change the exit code" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// One of every finding kind the walker can produce.
|
||||
const checks = try fieldChecksFor(
|
||||
arena,
|
||||
portfolio_mod.srf_schema,
|
||||
"portfolio.srf",
|
||||
\\#!srfv1
|
||||
\\shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01
|
||||
\\shares:num:1,open_date::2024-01-01,open_price:num:1,Account::X
|
||||
\\shares:num:1,open_date::2024-01-01,open_price:num:1,rate:num:5
|
||||
\\shares:num:1,open_date::2024-01-01,open_price:num:1,split_factor:num:2
|
||||
\\shares:num:1,open_date::2024-01-01,open_price:num:1,nonsense::x
|
||||
\\
|
||||
,
|
||||
);
|
||||
try std.testing.expect(checks.items.len > 1);
|
||||
for (checks.items) |c| {
|
||||
try std.testing.expect(c.status != .fail);
|
||||
}
|
||||
// And `countByStatus` agrees, which is what gates the exit code.
|
||||
const sections = [_]Section{.{ .title = "Files", .checks = checks.items }};
|
||||
try std.testing.expectEqual(@as(usize, 0), countByStatus(§ions, .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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
var prior_lookup_opt: ?PriorLotsLookup = null;
|
||||
defer if (prior_lookup_opt) |*p| p.deinit();
|
||||
if (prior_portfolio_opt) |pf| {
|
||||
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
|
||||
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots, ctx.today);
|
||||
}
|
||||
|
||||
// ── Synthesize lots ───────────────────────────────────────
|
||||
|
|
@ -585,7 +585,7 @@ const PriorLotsLookup = struct {
|
|||
map: std.StringHashMap(*const portfolio_mod.Lot),
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
|
||||
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup {
|
||||
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
|
||||
errdefer {
|
||||
var it = map.keyIterator();
|
||||
|
|
@ -594,12 +594,14 @@ const PriorLotsLookup = struct {
|
|||
}
|
||||
|
||||
for (lots) |*lot| {
|
||||
// Skip closed lots: they shouldn't anchor a re-import's
|
||||
// Skip sold lots: they shouldn't anchor a re-import's
|
||||
// open_date for a position the brokerage shows as held.
|
||||
// (Today's import doesn't write `close_date`/`close_price`
|
||||
// anyway, so this is also defensive against hand-edited
|
||||
// closed lots in the file.)
|
||||
if (lot.close_date != null) continue;
|
||||
// closed lots in the file.) "Sold", not "ended": import
|
||||
// carries `maturity_date` forward, so a brokered CD
|
||||
// re-imported on its maturity day must still anchor.
|
||||
if (lot.isSoldAsOf(today)) continue;
|
||||
// Cash lots have no symbol/account-meaningful identity
|
||||
// for matching across imports - skip.
|
||||
if (lot.security_type == .cash) continue;
|
||||
|
|
@ -994,7 +996,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
|||
.note = "imported fidelity 2024-01-15",
|
||||
},
|
||||
};
|
||||
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior_lookup.deinit();
|
||||
|
||||
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
|
||||
|
|
@ -1135,7 +1137,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
|
|||
.note = "imported fidelity 2024-06-01",
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
// Export shows 120 shares now (user bought more) at avg
|
||||
|
|
@ -1188,7 +1190,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
|
|||
.drip = true,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1258,7 +1260,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1320,7 +1322,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1360,7 +1362,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1407,7 +1409,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
// Export only shows AAPL (user sold all GOOG).
|
||||
|
|
@ -1440,7 +1442,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
|
|||
.security_type = .cash,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ pub fn display(
|
|||
summary.unrealized_gain_loss,
|
||||
watch_symbols,
|
||||
watch_prices,
|
||||
as_of,
|
||||
);
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
|
|
@ -436,10 +437,10 @@ pub fn display(
|
|||
|
||||
// Lot detail rows (always expanded for CLI)
|
||||
if (is_multi) {
|
||||
// Check if any lots are DRIP
|
||||
// Check if any lots go into the DRIP summary
|
||||
var has_drip = false;
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (lot.drip) {
|
||||
if (fmt.isDripSummarized(as_of, lot)) {
|
||||
has_drip = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -448,13 +449,13 @@ pub fn display(
|
|||
if (!has_drip) {
|
||||
// No DRIP: show all individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
|
||||
}
|
||||
} else {
|
||||
// Show non-DRIP lots individually
|
||||
// Show every lot the summary doesn't cover individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
if (!fmt.isDripSummarized(as_of, lot)) {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -605,8 +606,10 @@ pub fn display(
|
|||
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
|
||||
try cli.reset(out, color);
|
||||
|
||||
// Held lots only, so the rows add up to the TOTAL below.
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
const acct2: []const u8 = lot.account orelse "Unknown";
|
||||
var row_buf: [160]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
|
||||
|
|
@ -629,8 +632,10 @@ pub fn display(
|
|||
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
|
||||
try cli.reset(out, color);
|
||||
|
||||
// Held lots only, so the rows add up to the TOTAL below.
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .illiquid) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
var il_row_buf: [160]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
|
||||
}
|
||||
|
|
@ -683,7 +688,7 @@ pub fn display(
|
|||
/// Render one lot-detail row under its position row.
|
||||
///
|
||||
/// `effective_price` is the LOT's price, not its position's: pass
|
||||
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
|
||||
/// `views.effectivePriceFor(allocations, lot, as_of)`, which applies the lot's
|
||||
/// `price_ratio` and resolves `close_price`. Passing an
|
||||
/// `Allocation.current_price` straight through is the bug this
|
||||
/// parameter name exists to prevent - that price is the raw base-ticker
|
||||
|
|
@ -1471,7 +1476,7 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
|
|||
};
|
||||
const no_allocs: []const zfin.valuation.Allocation = &.{};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
|
||||
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
|
|
@ -1517,11 +1522,11 @@ test "printLotRow: a ratio'd lot values off its effective price and shows it" {
|
|||
.unrealized_return = 0.875,
|
||||
}};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
|
||||
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
|
||||
const eff_price = views.effectivePriceFor(&allocs, lots[0], zfin.Date.fromYmd(2026, 6, 1));
|
||||
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
|
||||
const out = w.buffered();
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,19 @@ const JournalRecord = union(enum) {
|
|||
note: NoteRecord,
|
||||
};
|
||||
|
||||
/// Schema contract for `acknowledgments.srf`. See
|
||||
/// `srf_lint.validateSchema`.
|
||||
///
|
||||
/// `Acknowledgment` does NOT redeclare the `type` tag field, unlike
|
||||
/// `projections.srf`'s variants - SRF's `to()` consumes the tag before
|
||||
/// recursing into the variant, so both spellings are legal. The shape
|
||||
/// builder accepts `type::` for every variant either way.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = JournalRecord;
|
||||
pub const file_label: []const u8 = "acknowledgments.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/acknowledgments-srf.md";
|
||||
};
|
||||
|
||||
/// In-memory ack with its notes already grouped. Built by `load`;
|
||||
/// consumed by callers that want "the ack and its reasoning together."
|
||||
pub const Entry = struct {
|
||||
|
|
|
|||
|
|
@ -742,10 +742,19 @@ pub const DripAggregation = struct {
|
|||
|
||||
/// Aggregate DRIP lots into short-term and long-term buckets.
|
||||
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
|
||||
/// Is `lot` folded into the DRIP ST/LT summary rather than shown as its
|
||||
/// own row? Only held DRIP lots are: a sold one is a closed lot like
|
||||
/// any other, so it gets an individual row with its closed status, and
|
||||
/// the summary's share count stays what you actually hold. The one test
|
||||
/// for the aggregator and both renderers (CLI `portfolio`, TUI tab).
|
||||
pub fn isDripSummarized(as_of: Date, lot: Lot) bool {
|
||||
return lot.drip and lot.lotIsOpenAsOf(as_of);
|
||||
}
|
||||
|
||||
pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
|
||||
var result: DripAggregation = .{};
|
||||
for (lots) |lot| {
|
||||
if (!lot.drip) continue;
|
||||
if (!isDripSummarized(as_of, lot)) continue;
|
||||
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT");
|
||||
const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
|
||||
bucket.lot_count += 1;
|
||||
|
|
@ -1786,3 +1795,31 @@ test "fmtSharpeOpt: asterisk and decimals" {
|
|||
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
|
||||
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
|
||||
}
|
||||
|
||||
test "aggregateDripLots: sold DRIP lots stay out of the summary" {
|
||||
// The summary is a count of what you hold. A sold DRIP lot used to
|
||||
// be folded in, inflating shares and cost; it now gets its own
|
||||
// closed row instead (`isDripSummarized`).
|
||||
const as_of = Date.fromYmd(2026, 1, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "VTI", .shares = 1.5, .open_date = Date.fromYmd(2023, 3, 1), .open_price = 200, .drip = true },
|
||||
.{ .symbol = "VTI", .shares = 2.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 210, .drip = true, .close_date = Date.fromYmd(2025, 12, 1), .close_price = 280 },
|
||||
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 190 },
|
||||
};
|
||||
const agg = aggregateDripLots(as_of, &lots);
|
||||
try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1.5), agg.lt.shares, 0.001);
|
||||
try std.testing.expect(agg.st.isEmpty());
|
||||
}
|
||||
|
||||
test "isDripSummarized: held DRIP only" {
|
||||
const as_of = Date.fromYmd(2026, 1, 1);
|
||||
const held: Lot = .{ .symbol = "VTI", .shares = 1, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .drip = true };
|
||||
try std.testing.expect(isDripSummarized(as_of, held));
|
||||
var sold = held;
|
||||
sold.close_date = Date.fromYmd(2025, 6, 1);
|
||||
try std.testing.expect(!isDripSummarized(as_of, sold));
|
||||
var not_drip = held;
|
||||
not_drip.drip = false;
|
||||
try std.testing.expect(!isDripSummarized(as_of, not_drip));
|
||||
}
|
||||
|
|
|
|||
28
src/git.zig
28
src/git.zig
|
|
@ -676,6 +676,21 @@ pub fn commitTimestamp(
|
|||
return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed;
|
||||
}
|
||||
|
||||
/// The UTC calendar day `ref` was committed (committer date, `%ct`).
|
||||
///
|
||||
/// The one place a commit becomes a `Date`: callers that need "which
|
||||
/// day was this commit" should use this rather than pairing
|
||||
/// `commitTimestamp` with `Date.fromEpoch` themselves.
|
||||
pub fn commitDate(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
ref: []const u8,
|
||||
) Error!Date {
|
||||
return Date.fromEpoch(try commitTimestamp(io, allocator, env, root, ref));
|
||||
}
|
||||
|
||||
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||
///
|
||||
/// Three modes selected by `since` / `until`:
|
||||
|
|
@ -1044,6 +1059,19 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
|
|||
for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
|
||||
}
|
||||
|
||||
test "commitDate is the committer day of commitTimestamp" {
|
||||
const allocator = std.testing.allocator;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
const ts = commitTimestamp(std.testing.io, allocator, &env, info.root, "HEAD") catch return;
|
||||
const d = try commitDate(std.testing.io, allocator, &env, info.root, "HEAD");
|
||||
try std.testing.expect(d.eql(Date.fromEpoch(ts)));
|
||||
}
|
||||
|
||||
test "commitAtOrBeforeDate returns null for date before repo existed" {
|
||||
const allocator = std.testing.allocator;
|
||||
var env = gitTestEnv(allocator);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ pub const ClassificationEntry = struct {
|
|||
splits_current_through: ?Date = null,
|
||||
};
|
||||
|
||||
/// Schema contract for `metadata.srf`. See `srf_lint.validateSchema`.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = ClassificationEntry;
|
||||
pub const file_label: []const u8 = "metadata.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/metadata-srf.md";
|
||||
};
|
||||
|
||||
/// Parsed classification data for the entire portfolio.
|
||||
pub const ClassificationMap = struct {
|
||||
entries: []ClassificationEntry,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const Date = @import("../Date.zig");
|
|||
const Candle = @import("candle.zig").Candle;
|
||||
const split = @import("split.zig");
|
||||
const Split = split.Split;
|
||||
const srf_lint = @import("../srf_lint.zig");
|
||||
|
||||
// ── Pricing model ────────────────────────────────────────────
|
||||
//
|
||||
|
|
@ -117,7 +118,7 @@ const Split = split.Split;
|
|||
// a merged group.
|
||||
//
|
||||
// So: per-lot display sites MUST price through
|
||||
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
|
||||
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot, as_of)`,
|
||||
// which resolves `close_price`, discriminates on `price_ratio`, and
|
||||
// applies the ratio via `Lot.effectivePrice`. Never read
|
||||
// `Allocation.current_price` into a per-lot calculation, and never
|
||||
|
|
@ -366,30 +367,66 @@ pub const Lot = struct {
|
|||
return self.lotIsOpenAsOf(as_of);
|
||||
}
|
||||
|
||||
/// Was the lot held at end-of-day on `as_of`?
|
||||
// ── Lot lifecycle: THE definition of open, sold, and ended ──
|
||||
//
|
||||
// Every "is this lot closed?" question in zfin is one of these
|
||||
// three. Nothing outside this struct reads `close_date`,
|
||||
// `close_price` or `maturity_date` to decide liveness - that was
|
||||
// how three different definitions of "closed" came to disagree
|
||||
// (holdings went by date, `contributions` and `import` by whether
|
||||
// `close_date` was present at all, lot rows by `close_price`). The
|
||||
// AGENTS.md grep rule keeps it that way.
|
||||
//
|
||||
// End-of-day semantics throughout: a date equal to `as_of` has
|
||||
// already happened. They are deliberately different questions:
|
||||
//
|
||||
// hasMaturedAsOf `maturity_date` arrived (options, CDs).
|
||||
// isSoldAsOf a SALE happened. `close_date` only. What
|
||||
// `contributions` needs: an option that expired
|
||||
// and is then recorded with a close is a sale, but
|
||||
// it had already ended at maturity, so testing
|
||||
// "ended" would never see the transition.
|
||||
// hasEndedAsOf sold OR matured. The lot is over, whether or
|
||||
// not it was ever bought (a not-yet-bought lot
|
||||
// hasn't ended).
|
||||
// lotIsOpenAsOf held: bought, and not ended.
|
||||
|
||||
/// Has a sale happened on or before `as_of`? True once `close_date`
|
||||
/// arrives; maturity doesn't count.
|
||||
pub fn isSoldAsOf(self: Lot, as_of: Date) bool {
|
||||
const cd = self.close_date orelse return false;
|
||||
return !as_of.lessThan(cd);
|
||||
}
|
||||
|
||||
/// Has `maturity_date` arrived (options, CDs)? A sale doesn't count.
|
||||
pub fn hasMaturedAsOf(self: Lot, as_of: Date) bool {
|
||||
const mat = self.maturity_date orelse return false;
|
||||
return !as_of.lessThan(mat);
|
||||
}
|
||||
|
||||
/// Is the lot over as of `as_of` - sold, or matured?
|
||||
pub fn hasEndedAsOf(self: Lot, as_of: Date) bool {
|
||||
return self.isSoldAsOf(as_of) or self.hasMaturedAsOf(as_of);
|
||||
}
|
||||
|
||||
/// Was the lot held at end-of-day on `as_of`? Bought, and not
|
||||
/// ended (see `hasEndedAsOf`).
|
||||
///
|
||||
/// Used by historical snapshot backfill (`zfin snapshot --as-of`)
|
||||
/// where "open" must be evaluated against the target date rather
|
||||
/// than wall-clock today. `isOpen()` delegates to this with
|
||||
/// today as `as_of`.
|
||||
///
|
||||
/// End-of-day semantics (see tests):
|
||||
/// - `open_date > as_of` -> not yet bought -> false
|
||||
/// - `close_date` on/before as_of -> sold that day or earlier -> false
|
||||
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
|
||||
/// - otherwise -> true
|
||||
/// than wall-clock today. `isOpen()` delegates to this.
|
||||
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
|
||||
// Not yet bought on `as_of`.
|
||||
if (as_of.lessThan(self.open_date)) return false;
|
||||
// Sold on or before `as_of`.
|
||||
if (self.close_date) |cd| {
|
||||
if (!as_of.lessThan(cd)) return false;
|
||||
return !self.hasEndedAsOf(as_of);
|
||||
}
|
||||
// Matured on or before `as_of` (options, CDs).
|
||||
if (self.maturity_date) |mat| {
|
||||
if (!as_of.lessThan(mat)) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
/// The price the lot was sold at, once the sale has happened.
|
||||
/// Null before `close_date` arrives even when `close_price` is set:
|
||||
/// a close price on a lot that's still held is not a price at all.
|
||||
pub fn closePriceAsOf(self: Lot, as_of: Date) ?f64 {
|
||||
if (!self.isSoldAsOf(as_of)) return null;
|
||||
return self.close_price;
|
||||
}
|
||||
|
||||
/// Cost basis: RAW shares x RAW open_price. Split-invariant
|
||||
|
|
@ -427,12 +464,13 @@ pub const Lot = struct {
|
|||
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
|
||||
}
|
||||
|
||||
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
|
||||
/// Returns null if the lot is still open. Stays on RAW shares - a
|
||||
/// closed lot is a completed round-trip whose recorded open/close are
|
||||
/// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
|
||||
pub fn realizedGainLoss(self: Lot) ?f64 {
|
||||
const cp = self.close_price orelse return null;
|
||||
/// Realized gain/loss once the lot is sold: shares * (close_price -
|
||||
/// open_price). Null until the sale happens, or when it was recorded
|
||||
/// without a `close_price`. Stays on RAW shares - a sold lot is a
|
||||
/// completed round-trip whose recorded open/close are consistent;
|
||||
/// `enrichSplits` leaves closed lots at `split_factor 1.0`.
|
||||
pub fn realizedGainLossAsOf(self: Lot, as_of: Date) ?f64 {
|
||||
const cp = self.closePriceAsOf(as_of) orelse return null;
|
||||
return self.shares * (cp - self.open_price);
|
||||
}
|
||||
|
||||
|
|
@ -441,11 +479,281 @@ pub const Lot = struct {
|
|||
return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
|
||||
}
|
||||
|
||||
pub fn returnPct(self: Lot, current_price: f64) f64 {
|
||||
/// Return on the lot: against the sale price once sold, otherwise
|
||||
/// against `current_price`.
|
||||
pub fn returnPct(self: Lot, current_price: f64, as_of: Date) f64 {
|
||||
if (self.open_price == 0) return 0;
|
||||
const price = if (self.close_price) |cp| cp else current_price;
|
||||
const price = self.closePriceAsOf(as_of) orelse current_price;
|
||||
return (price / self.effectiveOpenPrice()) - 1.0;
|
||||
}
|
||||
|
||||
/// Build a `Lot` from a parsed record, applying the one rule for
|
||||
/// which fields a lot may omit. Returns the name of the first
|
||||
/// missing field when the lot's type needs it.
|
||||
///
|
||||
/// - cash: `open_date` and `open_price` may be omitted and become
|
||||
/// `Date.epoch` and 1.0 - exactly what `zfin import` writes for
|
||||
/// cash, where a share IS a dollar. `shares` (the balance) is
|
||||
/// still required.
|
||||
/// - watch: all three may be omitted (epoch, 0, 0). A watch lot
|
||||
/// is a price to track, and `Portfolio.watchSymbols` reads only
|
||||
/// its `symbol`.
|
||||
/// - stock, option, cd, illiquid: all three are required. A
|
||||
/// default here would be silently wrong - cost 0 reads as a 100%
|
||||
/// gain and values a new contribution at $0, and an epoch date
|
||||
/// makes every split since 1970 look unhandled.
|
||||
///
|
||||
/// Why not plain defaults on `Lot`: the SRF writer omits a field
|
||||
/// equal to its default, so `zfin import` (which writes epoch dates
|
||||
/// on stock lots) would then produce files this rule rejects. Only
|
||||
/// the reader is relaxed; `Lot`, the writer, and every `Lot{...}`
|
||||
/// literal keep the fields required.
|
||||
pub fn fromParsed(p: ParsedLot) union(enum) { lot: Lot, missing: []const u8 } {
|
||||
const fill: ?struct { shares: ?f64, open_date: Date, open_price: f64 } = switch (p.security_type) {
|
||||
.cash => .{ .shares = null, .open_date = Date.epoch, .open_price = 1.0 },
|
||||
.watch => .{ .shares = 0, .open_date = Date.epoch, .open_price = 0 },
|
||||
.stock, .option, .cd, .illiquid => null,
|
||||
};
|
||||
|
||||
// SAFETY: every field is assigned by the loop below, or the
|
||||
// function returns `.missing` before `lot` is read.
|
||||
var lot: Lot = undefined;
|
||||
inline for (std.meta.fields(Lot)) |f| {
|
||||
if (comptime isTypeDependent(f.name)) {
|
||||
if (@field(p, f.name)) |v| {
|
||||
@field(lot, f.name) = v;
|
||||
} else {
|
||||
const fallback: ?f.type = if (fill) |d| @field(d, f.name) else null;
|
||||
@field(lot, f.name) = fallback orelse return .{ .missing = f.name };
|
||||
}
|
||||
} else {
|
||||
@field(lot, f.name) = @field(p, f.name);
|
||||
}
|
||||
}
|
||||
return .{ .lot = lot };
|
||||
}
|
||||
};
|
||||
|
||||
/// `Lot` fields that only some lot types need, and which are therefore
|
||||
/// optional in `ParsedLot`. See `Lot.fromParsed` for the rule.
|
||||
const type_dependent_fields = [_][]const u8{ "shares", "open_date", "open_price" };
|
||||
|
||||
fn isTypeDependent(comptime name: []const u8) bool {
|
||||
for (type_dependent_fields) |n| {
|
||||
if (std.mem.eql(u8, n, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// `Lot` as read from a hand-edited portfolio file: the same fields,
|
||||
/// except the `type_dependent_fields` are optional so a record that
|
||||
/// legitimately omits them (a cash or watch lot) still coerces.
|
||||
/// `Lot.fromParsed` converts it back.
|
||||
///
|
||||
/// DERIVED from `Lot` at comptime rather than written out, so the two
|
||||
/// cannot drift: a field added to `Lot` appears here automatically,
|
||||
/// with the same type and default, and `srf_lint`'s valid-name set
|
||||
/// (taken from `Lot`) stays the set this actually parses.
|
||||
pub const ParsedLot = blk: {
|
||||
const fields = std.meta.fields(Lot);
|
||||
for (type_dependent_fields) |n| {
|
||||
if (!@hasField(Lot, n)) @compileError("type_dependent_fields names `" ++ n ++ "`, which is not a field of Lot");
|
||||
}
|
||||
var names: [fields.len][]const u8 = undefined;
|
||||
var types: [fields.len]type = undefined;
|
||||
var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined;
|
||||
for (fields, 0..) |f, i| {
|
||||
names[i] = f.name;
|
||||
if (isTypeDependent(f.name)) {
|
||||
const none: ?f.type = null;
|
||||
types[i] = ?f.type;
|
||||
attrs[i] = .{ .default_value_ptr = &none };
|
||||
} else {
|
||||
types[i] = f.type;
|
||||
attrs[i] = .{ .default_value_ptr = f.default_value_ptr };
|
||||
}
|
||||
}
|
||||
break :blk @Struct(.auto, null, &names, &types, &attrs);
|
||||
};
|
||||
|
||||
/// Schema contract for `portfolio.srf`, consumed by `srf_lint` and
|
||||
/// rendered by `zfin doctor` / `zfin audit`.
|
||||
///
|
||||
/// Lives here rather than in `srf_lint.zig` on purpose: rules kept away
|
||||
/// from the fields they describe drift from them. `field_rules` is
|
||||
/// checked for exhaustiveness over `std.meta.fields(Lot)` at comptime
|
||||
/// in both directions, so adding a field to `Lot` fails the build until
|
||||
/// it is classified here, and renaming one fails the build too.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = Lot;
|
||||
pub const file_label: []const u8 = "portfolio.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/portfolio-srf.md";
|
||||
|
||||
/// Fields deliberately absent from the reference doc. Everything
|
||||
/// else must appear there or the doc-sync test fails.
|
||||
pub const undocumented = [_][]const u8{
|
||||
// DERIVED by `enrichSplits`; documenting it would invite
|
||||
// hand-editing, which `field_rules` flags below.
|
||||
"split_factor",
|
||||
};
|
||||
|
||||
pub const scope_discriminator: []const u8 = "security_type";
|
||||
|
||||
/// Which `LotType`s each field is actually read for.
|
||||
///
|
||||
/// A `.only` entry is a claim that the value is SILENTLY IGNORED
|
||||
/// for every other type, so each one cites the gate that proves it.
|
||||
/// When a field's reach is ambiguous the entry is deliberately
|
||||
/// permissive (no `.only`) - a missed nudge costs nothing, while a
|
||||
/// false "zfin ignores this" would send someone chasing a
|
||||
/// non-existent bug.
|
||||
pub const field_rules = [_]srf_lint.Rule{
|
||||
.{ .name = "symbol" },
|
||||
.{ .name = "shares" },
|
||||
.{ .name = "open_date" },
|
||||
.{ .name = "open_price" },
|
||||
.{ .name = "close_date" },
|
||||
.{ .name = "close_price" },
|
||||
.{ .name = "note" },
|
||||
.{ .name = "label" },
|
||||
.{ .name = "account" },
|
||||
.{ .name = "security_type" },
|
||||
|
||||
// Read ungated by `lotIsOpenAsOf` (this file, "Matured on or
|
||||
// before `as_of`"), so a maturity on a stock lot really does
|
||||
// close it. NOT option/cd-only despite the doc comment.
|
||||
.{ .name = "maturity_date" },
|
||||
|
||||
// CD yield. Read only by `analytics/reconcile/common.zig`'s
|
||||
// CD-allowance math and the CD table's rate column in
|
||||
// `views/portfolio_sections.zig`.
|
||||
.{ .name = "rate", .only = &.{"cd"} },
|
||||
|
||||
// Dividend-reinvestment grouping is a stock-table concern, but
|
||||
// `commands/contributions.zig`'s lot differ reads it for any
|
||||
// type. Permissive.
|
||||
.{ .name = "drip" },
|
||||
|
||||
// Pricing fields. `analytics/valuation.zig`'s
|
||||
// `buildFallbackPrices` gates manual prices to `.stock`, but
|
||||
// `commands/audit.zig`'s reconcile price map reads `lot.price`
|
||||
// ungated - so a manual price on a non-stock lot is not
|
||||
// reliably ignored. Permissive until those two agree.
|
||||
.{ .name = "ticker" },
|
||||
.{ .name = "price" },
|
||||
.{ .name = "price_date" },
|
||||
.{ .name = "price_ratio" },
|
||||
|
||||
// Populated by `enrichSplits`, never by hand.
|
||||
.{ .name = "split_factor", .derived = true },
|
||||
|
||||
// Option mechanics. Every read is behind an `.option` gate:
|
||||
// `models/option.zig`'s lot matcher, `analytics/valuation.zig`'s
|
||||
// covered-call detection, and the three `@abs(shares) *
|
||||
// open_price * multiplier` sites (this file's
|
||||
// `totalOptionCostAsOf`, `analytics/analysis.zig`, and
|
||||
// `commands/snapshot.zig`).
|
||||
.{ .name = "underlying", .only = &.{"option"} },
|
||||
.{ .name = "strike", .only = &.{"option"} },
|
||||
.{ .name = "multiplier", .only = &.{"option"} },
|
||||
.{ .name = "option_type", .only = &.{"option"} },
|
||||
};
|
||||
|
||||
/// Lot lifecycle rules - dates and close fields that parse fine but
|
||||
/// describe a lot that cannot exist.
|
||||
///
|
||||
/// zfin now reads all of these the same way everywhere (see the
|
||||
/// lifecycle block on `Lot`: `isSoldAsOf`, `hasEndedAsOf`,
|
||||
/// `lotIsOpenAsOf`), so none of them makes the program disagree
|
||||
/// with itself any more. They are still flagged because each one
|
||||
/// almost certainly isn't what you meant: a future `close_date` is
|
||||
/// usually a mistyped year, and it leaves the lot held until then.
|
||||
///
|
||||
/// Watch lots are skipped throughout: `Portfolio.watchSymbols` reads
|
||||
/// only `symbol`, so their dates are inert.
|
||||
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
|
||||
if (rec.security_type == .watch) return;
|
||||
const a = sink.allocator;
|
||||
const name = lotName(rec);
|
||||
|
||||
if (rec.close_date) |cd| {
|
||||
// End-of-day semantics: a close dated today is already
|
||||
// closed, so only strictly-later dates are wrong.
|
||||
if (ctx.today.lessThan(cd)) {
|
||||
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} close_date {f} is in the future - the lot is still counted as held until then",
|
||||
.{ name, cd },
|
||||
));
|
||||
}
|
||||
// Equal is a same-day round trip, which is legitimate.
|
||||
if (cd.lessThan(rec.open_date)) {
|
||||
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} close_date {f} is before open_date {f} - the lot can never have been held",
|
||||
.{ name, cd, rec.open_date },
|
||||
));
|
||||
}
|
||||
// Stock-only: both position builders skip non-stock lots,
|
||||
// so realized P&L is a stock concept. A cash or CD lot
|
||||
// closed without a price is plausible - its shares ARE its
|
||||
// dollar value.
|
||||
if (rec.close_price == null and rec.security_type == .stock) {
|
||||
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} has close_date but no close_price - the sale's realized gain is recorded as 0",
|
||||
.{name},
|
||||
));
|
||||
}
|
||||
} else if (rec.close_price != null) {
|
||||
// `Lot.closePriceAsOf` ignores a close price until the sale
|
||||
// happens, so without a `close_date` it is never used.
|
||||
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
.{name},
|
||||
));
|
||||
}
|
||||
|
||||
if (ctx.today.lessThan(rec.open_date)) {
|
||||
try sink.addOwned(.semantic, "open_date", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} open_date {f} is in the future - the lot is not counted as held until then",
|
||||
.{ name, rec.open_date },
|
||||
));
|
||||
}
|
||||
|
||||
// Stock-only: manual-price staleness (`commands/audit/hygiene.zig`)
|
||||
// only examines stock lots, so that is the only place a future
|
||||
// date suppresses anything.
|
||||
if (rec.security_type == .stock) {
|
||||
if (rec.price_date) |pd| {
|
||||
if (ctx.today.lessThan(pd)) {
|
||||
try sink.addOwned(.semantic, "price_date", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} price_date {f} is in the future - the manual price counts as fresh, so it will not be flagged stale",
|
||||
.{ name, pd },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a finding names the lot. Cash lots often have no symbol, so
|
||||
/// fall back to the type rather than printing an empty string - the
|
||||
/// line number already pins down which record it is.
|
||||
fn lotName(lot: Lot) []const u8 {
|
||||
const s = lot.displaySymbol();
|
||||
if (s.len > 0) return s;
|
||||
return switch (lot.security_type) {
|
||||
.cash => "cash lot",
|
||||
.cd => "CD lot",
|
||||
.option => "option lot",
|
||||
.illiquid => "illiquid lot",
|
||||
.stock => "stock lot",
|
||||
.watch => "watch lot",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Populate each open stock lot's `split_factor` from the fetched split
|
||||
|
|
@ -693,7 +1001,7 @@ pub const Portfolio = struct {
|
|||
const not_yet_opened = as_of.lessThan(lot.open_date);
|
||||
if (!not_yet_opened) {
|
||||
pos.closed_lots += 1;
|
||||
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -754,9 +1062,12 @@ pub const Portfolio = struct {
|
|||
pos.shares += lot.effectiveShares();
|
||||
pos.total_cost += lot.costBasis();
|
||||
pos.open_lots += 1;
|
||||
} else {
|
||||
} else if (!as_of.lessThan(lot.open_date)) {
|
||||
// Not held and already bought, so it has ended. A
|
||||
// not-yet-bought lot is neither - `positionsAsOf` skips
|
||||
// it the same way instead of counting it as closed.
|
||||
pos.closed_lots += 1;
|
||||
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -775,11 +1086,15 @@ pub const Portfolio = struct {
|
|||
return final.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Total cash for a single account.
|
||||
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
||||
/// Total cash held by a single account on `as_of`. Closed cash
|
||||
/// lots are excluded, like every other total - this one used to
|
||||
/// count them, and audit's reconciliation then reported a
|
||||
/// discrepancy against a brokerage that (correctly) didn't.
|
||||
pub fn cashForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
||||
}
|
||||
|
|
@ -864,12 +1179,12 @@ pub const Portfolio = struct {
|
|||
return total;
|
||||
}
|
||||
|
||||
/// Total realized P&L from all closed stock lots.
|
||||
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
|
||||
/// Total realized P&L from stock lots sold on or before `as_of`.
|
||||
pub fn totalRealizedGainLoss(self: Portfolio, as_of: Date) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type == .stock) {
|
||||
if (lot.realizedGainLoss()) |pnl| total += pnl;
|
||||
if (lot.realizedGainLossAsOf(as_of)) |pnl| total += pnl;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
|
|
@ -1131,7 +1446,7 @@ test "lot basics" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
|
||||
try std.testing.expect(lot.realizedGainLoss() == null);
|
||||
try std.testing.expect(lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)) == null);
|
||||
}
|
||||
|
||||
test "closed lot" {
|
||||
|
|
@ -1144,8 +1459,8 @@ test "closed lot" {
|
|||
.close_price = 200.0,
|
||||
};
|
||||
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)).?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0, Date.fromYmd(2026, 5, 8)), 0.001);
|
||||
}
|
||||
|
||||
test "portfolio positions" {
|
||||
|
|
@ -1217,15 +1532,18 @@ test "Position.displaySymbol: label orelse symbol" {
|
|||
}
|
||||
|
||||
test "Lot.returnPct" {
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
// Open lot: uses current_price param
|
||||
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
|
||||
// Closed lot: uses close_price, ignores current_price
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150, as_of), 0.001);
|
||||
// Sold lot: uses close_price, ignores current_price
|
||||
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999, as_of), 0.001);
|
||||
// Sale not yet happened: still priced live, close_price ignored
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), closed_lot.returnPct(150, Date.fromYmd(2024, 5, 31)), 0.001);
|
||||
// Zero open_price: returns 0
|
||||
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100, as_of), 0.001);
|
||||
}
|
||||
|
||||
test "Portfolio totals" {
|
||||
|
|
@ -1243,7 +1561,7 @@ test "Portfolio totals" {
|
|||
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalCash
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalIlliquid
|
||||
|
|
@ -2163,3 +2481,344 @@ test "extraPriceSymbols: order is stable - watch lots first, then the watchlist
|
|||
try std.testing.expectEqualStrings("QTUM", out[3]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── srf_schema.semanticCheck: lot lifecycle rules ──
|
||||
|
||||
/// `today` for these tests. Fixed, so a verdict can't flip as the
|
||||
/// calendar moves past a fixture date.
|
||||
const lint_today = Date.fromYmd(2026, 1, 1);
|
||||
|
||||
/// Run both lint passes over `data` as portfolio.srf and return only
|
||||
/// the semantic findings' descriptions, joined by newlines. Caller
|
||||
/// frees.
|
||||
fn lifecycleFindings(data: []const u8) ![]u8 {
|
||||
const allocator = std.testing.allocator;
|
||||
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
|
||||
defer r.deinit();
|
||||
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = lint_today });
|
||||
r.sort();
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
errdefer aw.deinit();
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
for (r.findings) |f| {
|
||||
if (f.kind != .semantic) continue;
|
||||
try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
|
||||
}
|
||||
return aw.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn expectLifecycle(data: []const u8, want: []const []const u8) !void {
|
||||
const got = try lifecycleFindings(data);
|
||||
defer std.testing.allocator.free(got);
|
||||
var lines: usize = 0;
|
||||
var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, got, "\n"), '\n');
|
||||
while (it.next()) |l| {
|
||||
if (l.len > 0) lines += 1;
|
||||
}
|
||||
for (want) |w| {
|
||||
// On a miss, let `expectEqualStrings` print both sides: it always
|
||||
// fails here (a substring was not found), and it reports through
|
||||
// `std.testing` rather than a hand-rolled print.
|
||||
if (std.mem.indexOf(u8, got, w) == null) return std.testing.expectEqualStrings(w, got);
|
||||
}
|
||||
if (lines != want.len) {
|
||||
const joined = try std.mem.join(std.testing.allocator, "\n", want);
|
||||
defer std.testing.allocator.free(joined);
|
||||
return std.testing.expectEqualStrings(joined, got);
|
||||
}
|
||||
}
|
||||
|
||||
test "lifecycle: a fully valid open and closed lot are clean" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220
|
||||
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01,close_price:num:560
|
||||
\\
|
||||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: future close_date is flagged" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14,close_price:num:300
|
||||
\\
|
||||
, &.{"2: VTI close_date 2062-03-14 is in the future - the lot is still counted as held until then"});
|
||||
}
|
||||
|
||||
test "lifecycle: a close dated today is already closed, not future" {
|
||||
// End-of-day semantics, matching `lotIsOpenAsOf`.
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2026-01-01,close_price:num:300
|
||||
\\
|
||||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: close before open is flagged; a same-day round trip is not" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-06-01,open_price:num:220,close_date::2024-05-01,close_price:num:230
|
||||
\\symbol::SPY,shares:num:10,open_date::2024-06-01,open_price:num:470,close_date::2024-06-01,close_price:num:471
|
||||
\\
|
||||
, &.{"2: VTI close_date 2024-05-01 is before open_date 2024-06-01"});
|
||||
}
|
||||
|
||||
test "lifecycle: stock lot closed without a price loses its realized gain" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01
|
||||
\\
|
||||
, &.{"2: SPY has close_date but no close_price - the sale's realized gain is recorded as 0"});
|
||||
}
|
||||
|
||||
test "lifecycle: cash and CD lots closed without a price are fine" {
|
||||
// Their shares ARE their dollar value; there is no realized P&L to lose.
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\security_type::cash,shares:num:5000,open_date::2024-01-01,open_price:num:1,close_date::2025-01-01,account::Sample Brokerage
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_date::2025-01-01
|
||||
\\
|
||||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: close_price without close_date is ignored, for every type" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_price:num:560
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:1,close_price:num:1
|
||||
\\
|
||||
, &.{
|
||||
"2: SPY has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
"3: CD1 has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
});
|
||||
}
|
||||
|
||||
test "lifecycle: future open_date is flagged" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2062-01-15,open_price:num:220
|
||||
\\
|
||||
, &.{"2: VTI open_date 2062-01-15 is in the future - the lot is not counted as held until then"});
|
||||
}
|
||||
|
||||
test "lifecycle: future price_date is flagged on stock lots only" {
|
||||
// Manual-price staleness only ever examines stock lots, so a future
|
||||
// price_date on a CD suppresses nothing and is left alone.
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::NON40,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2062-02-27
|
||||
\\symbol::NON41,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2025-12-31
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,price_date::2062-01-01
|
||||
\\
|
||||
, &.{"2: NON40 price_date 2062-02-27 is in the future - the manual price counts as fresh"});
|
||||
}
|
||||
|
||||
test "lifecycle: watch lots are exempt" {
|
||||
// `Portfolio.watchSymbols` reads only `symbol`; dates are inert.
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\security_type::watch,symbol::NVDA,shares:num:0,open_date::2062-01-01,open_price:num:0,close_date::2063-01-01
|
||||
\\
|
||||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: maturity_date in the future is legitimate" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2025-06-01,open_price:num:10000,maturity_date::2027-06-01,rate:num:4.5
|
||||
\\
|
||||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: two bad lots stay two findings, and one lot can have several" {
|
||||
// Rollup identity includes `detail`, which names the symbol, so the
|
||||
// same rule on different lots must not collapse into one line.
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14
|
||||
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2062-03-14,close_price:num:1
|
||||
\\
|
||||
, &.{
|
||||
"2: VTI close_date 2062-03-14 is in the future",
|
||||
"2: VTI has close_date but no close_price",
|
||||
"3: SPY close_date 2062-03-14 is in the future",
|
||||
});
|
||||
}
|
||||
|
||||
test "lifecycle: a lot without a symbol is named by its type" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\security_type::cash,shares:num:5000,open_date::2062-01-01,open_price:num:1,account::Sample Brokerage
|
||||
\\
|
||||
, &.{"2: cash lot open_date 2062-01-01 is in the future"});
|
||||
}
|
||||
|
||||
// ── ParsedLot / Lot.fromParsed ──
|
||||
|
||||
/// `lot` as the record a hand-edited file would produce.
|
||||
fn parsedFrom(lot: Lot) ParsedLot {
|
||||
// SAFETY: every field is assigned by the loop below.
|
||||
var p: ParsedLot = undefined;
|
||||
inline for (std.meta.fields(Lot)) |f| @field(p, f.name) = @field(lot, f.name);
|
||||
return p;
|
||||
}
|
||||
|
||||
test "ParsedLot: same field names as Lot, only the type-dependent ones optional" {
|
||||
const lot_fields = std.meta.fields(Lot);
|
||||
const parsed_fields = std.meta.fields(ParsedLot);
|
||||
try std.testing.expectEqual(lot_fields.len, parsed_fields.len);
|
||||
inline for (lot_fields, parsed_fields) |lf, pf| {
|
||||
try std.testing.expectEqualStrings(lf.name, pf.name);
|
||||
if (comptime isTypeDependent(lf.name)) {
|
||||
try std.testing.expect(pf.type == ?lf.type);
|
||||
} else {
|
||||
try std.testing.expect(pf.type == lf.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "fromParsed: a complete record round-trips unchanged" {
|
||||
const lot = Lot{
|
||||
.symbol = "VTI",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 1, 15),
|
||||
.open_price = 220,
|
||||
.close_date = Date.fromYmd(2025, 1, 15),
|
||||
.close_price = 260,
|
||||
.note = "n",
|
||||
.label = "L",
|
||||
.account = "Sample IRA",
|
||||
.ticker = "VTSAX",
|
||||
.price = 1,
|
||||
.price_date = Date.fromYmd(2025, 1, 1),
|
||||
.price_ratio = 2,
|
||||
.drip = true,
|
||||
};
|
||||
const got = Lot.fromParsed(parsedFrom(lot)).lot;
|
||||
try std.testing.expect(std.meta.eql(lot, got));
|
||||
}
|
||||
|
||||
test "fromParsed: cash may omit open_date and open_price, not shares" {
|
||||
var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.epoch, .open_price = 1, .security_type = .cash });
|
||||
p.open_date = null;
|
||||
p.open_price = null;
|
||||
const lot = Lot.fromParsed(p).lot;
|
||||
try std.testing.expect(lot.open_date.eql(Date.epoch));
|
||||
try std.testing.expectEqual(@as(f64, 1.0), lot.open_price);
|
||||
try std.testing.expectEqual(@as(f64, 5000), lot.shares);
|
||||
|
||||
// The balance itself is still required.
|
||||
p.shares = null;
|
||||
try std.testing.expectEqualStrings("shares", Lot.fromParsed(p).missing);
|
||||
}
|
||||
|
||||
test "fromParsed: an explicit value always wins over the fill" {
|
||||
var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2026, 4, 30), .open_price = 1, .security_type = .cash });
|
||||
p.open_price = null;
|
||||
const lot = Lot.fromParsed(p).lot;
|
||||
try std.testing.expect(lot.open_date.eql(Date.fromYmd(2026, 4, 30)));
|
||||
}
|
||||
|
||||
test "fromParsed: watch may omit all three" {
|
||||
var p = parsedFrom(.{ .symbol = "NVDA", .shares = 1, .open_date = Date.epoch, .open_price = 1, .security_type = .watch });
|
||||
p.shares = null;
|
||||
p.open_date = null;
|
||||
p.open_price = null;
|
||||
const lot = Lot.fromParsed(p).lot;
|
||||
try std.testing.expectEqual(@as(f64, 0), lot.shares);
|
||||
try std.testing.expectEqual(@as(f64, 0), lot.open_price);
|
||||
try std.testing.expect(lot.open_date.eql(Date.epoch));
|
||||
}
|
||||
|
||||
test "fromParsed: every other type requires all three, and names the missing one" {
|
||||
const needs_all = [_]LotType{ .stock, .option, .cd, .illiquid };
|
||||
inline for (needs_all) |t| {
|
||||
inline for (type_dependent_fields) |field| {
|
||||
var p = parsedFrom(.{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1, .security_type = t });
|
||||
@field(p, field) = null;
|
||||
try std.testing.expectEqualStrings(field, Lot.fromParsed(p).missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "cashForAccount: counts only cash held on as_of" {
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "CASH", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA" },
|
||||
.{ .symbol = "CASH", .shares = 250, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA", .close_date = Date.fromYmd(2026, 3, 1) },
|
||||
.{ .symbol = "CASH", .shares = 99, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample Roth" },
|
||||
};
|
||||
const pf = testPortfolio(&lots);
|
||||
// Before the close, both Sample IRA lots count; after, only one.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1250), pf.cashForAccount(Date.fromYmd(2026, 2, 1), "Sample IRA"), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA"), 0.001);
|
||||
// And it agrees with the portfolio-wide total that already filtered.
|
||||
try std.testing.expectApproxEqAbs(pf.totalCash(Date.fromYmd(2026, 3, 1)), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA") + pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample Roth"), 0.001);
|
||||
}
|
||||
|
||||
// ── Lot lifecycle ──
|
||||
|
||||
test "lifecycle predicates: sold, matured, ended, held" {
|
||||
const d = Date.fromYmd;
|
||||
const bought = d(2024, 1, 1);
|
||||
const plain: Lot = .{ .symbol = "X", .shares = 1, .open_date = bought, .open_price = 1 };
|
||||
|
||||
// Held, nothing scheduled.
|
||||
try std.testing.expect(!plain.isSoldAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(!plain.hasMaturedAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(!plain.hasEndedAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(plain.lotIsOpenAsOf(d(2025, 1, 1)));
|
||||
|
||||
// Sold: end-of-day, so the close date itself is already sold.
|
||||
var sold = plain;
|
||||
sold.close_date = d(2025, 6, 1);
|
||||
try std.testing.expect(!sold.isSoldAsOf(d(2025, 5, 31)));
|
||||
try std.testing.expect(sold.isSoldAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(sold.hasEndedAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(!sold.hasMaturedAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(!sold.lotIsOpenAsOf(d(2025, 6, 1)));
|
||||
|
||||
// Matured: ended and not held, but NOT sold - this is the case
|
||||
// `contributions` depends on, so an expired option recorded with a
|
||||
// close afterwards is still seen as a sale.
|
||||
var matured = plain;
|
||||
matured.maturity_date = d(2025, 3, 1);
|
||||
try std.testing.expect(matured.hasMaturedAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(matured.hasEndedAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(!matured.isSoldAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(!matured.lotIsOpenAsOf(d(2025, 3, 1)));
|
||||
|
||||
// Not yet bought: neither held nor ended.
|
||||
try std.testing.expect(!plain.lotIsOpenAsOf(d(2023, 12, 31)));
|
||||
try std.testing.expect(!plain.hasEndedAsOf(d(2023, 12, 31)));
|
||||
}
|
||||
|
||||
test "closePriceAsOf / realizedGainLossAsOf: only once sold" {
|
||||
const d = Date.fromYmd;
|
||||
var lot: Lot = .{ .symbol = "X", .shares = 10, .open_date = d(2024, 1, 1), .open_price = 100, .close_price = 130 };
|
||||
// A close price with no close date is not a sale.
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.closePriceAsOf(d(2026, 1, 1)));
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
|
||||
// Nor is one whose date hasn't arrived.
|
||||
lot.close_date = d(2025, 6, 1);
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2025, 5, 31)));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 300), lot.realizedGainLossAsOf(d(2025, 6, 1)).?, 0.001);
|
||||
// Sold but recorded without a price: nothing to realize from.
|
||||
lot.close_price = null;
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
|
||||
}
|
||||
|
||||
test "positionsForAccount: a not-yet-bought lot isn't counted as closed" {
|
||||
// It used to land in `closed_lots` because "not held" was taken to
|
||||
// mean "closed"; `positionsAsOf` already skipped it.
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
|
||||
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2027, 1, 1), .open_price = 250, .account = "Sample IRA" },
|
||||
};
|
||||
const pf = testPortfolio(&lots);
|
||||
const positions = try pf.positionsForAccount(Date.fromYmd(2026, 1, 1), std.testing.allocator, "Sample IRA");
|
||||
defer std.testing.allocator.free(positions);
|
||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), positions[0].closed_lots);
|
||||
try std.testing.expectEqual(@as(usize, 1), positions[0].open_lots);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,14 @@ pub const TransferRecord = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// Schema contract for `transaction_log.srf`. See
|
||||
/// `srf_lint.validateSchema`.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = TransferRecord;
|
||||
pub const file_label: []const u8 = "transaction_log.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/transaction-log-srf.md";
|
||||
};
|
||||
|
||||
/// Parsed transaction log. `transfers` is allocator-owned; all string
|
||||
/// fields on each record (including the symbol inside `dest_lot.lot`)
|
||||
/// are also owned by the log's allocator.
|
||||
|
|
|
|||
|
|
@ -5485,24 +5485,43 @@ test "dividendTtl: the chosen clock is what actually lands on disk" {
|
|||
|
||||
var store = cache.Store.init(io, allocator, dir_path);
|
||||
const today = fmt.todayDate(io);
|
||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
|
||||
// `store.write` reads the clock itself, so a single `now` captured
|
||||
// here can be a second behind the one it stamps - which flaked this
|
||||
// test as `expected 516344, found 516345`. Bracket each write
|
||||
// instead: the stamped expiry must be `offset` past SOME instant in
|
||||
// [before, after].
|
||||
const Bracket = struct {
|
||||
fn expect(expires: i64, offset: i64, before_s: i64, after_s: i64) !void {
|
||||
try std.testing.expect(expires >= before_s + offset);
|
||||
try std.testing.expect(expires <= after_s + offset);
|
||||
}
|
||||
};
|
||||
|
||||
// Established payer -> ~14 days.
|
||||
var established = quarterlySeries(6, today.addDays(-30), 91);
|
||||
const long_before = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
store.write(Dividend, "TESTA", established[0..], DataService.dividendTtl(&established));
|
||||
const long_after = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
const long_expires = readExpires(io, allocator, dir_path, "TESTA") orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(
|
||||
try Bracket.expect(
|
||||
long_expires,
|
||||
cache.computeExpires(0, DataService.dividendTtl(&established), "TESTA"),
|
||||
long_expires - now_s,
|
||||
long_before,
|
||||
long_after,
|
||||
);
|
||||
|
||||
// Sparse history -> ~6 days.
|
||||
var sparse = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 } });
|
||||
const short_before = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
store.write(Dividend, "TESTB", sparse[0..], DataService.dividendTtl(&sparse));
|
||||
const short_after = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
const short_expires = readExpires(io, allocator, dir_path, "TESTB") orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(
|
||||
try Bracket.expect(
|
||||
short_expires,
|
||||
cache.computeExpires(0, DataService.dividendTtl(&sparse), "TESTB"),
|
||||
short_expires - now_s,
|
||||
short_before,
|
||||
short_after,
|
||||
);
|
||||
|
||||
// And the long one really is longer, so nobody can "simplify" the two
|
||||
|
|
|
|||
1896
src/srf_lint.zig
Normal file
1896
src/srf_lint.zig
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,17 @@ const srf = @import("srf");
|
|||
/// `projections.srf`, `imported_values.srf`, `acknowledgments.srf`, and
|
||||
/// the keybind config.
|
||||
///
|
||||
/// **`grep -rn srf_opts.user_edited src/` is the authoritative index of
|
||||
/// hand-edited SRF files, and `src/srf_lint.zig`'s `schemas` registry
|
||||
/// must cover the same set.** A new parse site here needs a matching
|
||||
/// `srf_schema` on its model and an entry in that registry, or the
|
||||
/// file's field names go unchecked - SRF silently discards a key that
|
||||
/// names no field of the target struct, so a typo in an unregistered
|
||||
/// file is invisible. (The one deliberate exception is
|
||||
/// `imported_values.srf`, which is machine-generated; see the registry's
|
||||
/// doc comment.) Zig cannot enforce this - there is no way to ask which
|
||||
/// files reference a constant - so it is a convention, hence this note.
|
||||
///
|
||||
/// Accepts a string where a number was declared, because a hand-typed
|
||||
/// `close_price::200.00` instead of `close_price:num:200.00` is a
|
||||
/// slip, not a different intent. SRF's own doc says as much: strict
|
||||
|
|
|
|||
234
src/tui.zig
234
src/tui.zig
|
|
@ -106,6 +106,109 @@ pub const Tab = blk: {
|
|||
break :blk @Enum(u8, .exhaustive, &names, &values);
|
||||
};
|
||||
|
||||
// ── keys.srf tab-scoped bindings ──────────────────────────────
|
||||
|
||||
/// Why a `keys.srf` tab-scoped binding can never fire.
|
||||
pub const ScopedBindingCheck = enum {
|
||||
ok,
|
||||
/// `scope::` names no tab, so the binding lands in a bucket
|
||||
/// nothing ever consults.
|
||||
unknown_scope,
|
||||
/// The tab exists, but `action::` is not one of its `Action` enum
|
||||
/// tags. The dispatcher finds no match and the key does nothing -
|
||||
/// and because any override for a tab replaces its defaults
|
||||
/// wholesale, it does not fall back to a default binding either.
|
||||
unknown_action,
|
||||
};
|
||||
|
||||
/// The one definition of "is this tab-scoped binding real", derived
|
||||
/// from `tab_modules` at comptime. `keybinds.zig` deliberately doesn't
|
||||
/// know about tabs, so it can't check this while loading; the TUI
|
||||
/// startup path (`warnScopedBindingProblems`) and `zfin doctor` (via
|
||||
/// `keybinds.srf_schema.semanticCheck`) both ask here instead.
|
||||
///
|
||||
/// `scope::global` is not a tab scope; callers handle it before this.
|
||||
pub fn checkScopedBinding(scope: []const u8, action: []const u8) ScopedBindingCheck {
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
if (std.mem.eql(u8, field.name, scope)) {
|
||||
const ActionT = @field(tab_modules, field.name).Action;
|
||||
return if (std.meta.stringToEnum(ActionT, action) != null) .ok else .unknown_action;
|
||||
}
|
||||
}
|
||||
return .unknown_scope;
|
||||
}
|
||||
|
||||
/// "global, portfolio, analysis, ..." - every valid `scope::` value.
|
||||
const valid_scope_list: []const u8 = blk: {
|
||||
var s: []const u8 = "global";
|
||||
for (std.meta.fields(@TypeOf(tab_modules))) |f| s = s ++ ", " ++ f.name;
|
||||
break :blk s;
|
||||
};
|
||||
|
||||
/// Comma-separated `Action` tags for tab `scope`, or null for a scope
|
||||
/// that is not a tab. Empty for a tab with no local actions.
|
||||
fn tabActionList(scope: []const u8) ?[]const u8 {
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
if (std.mem.eql(u8, field.name, scope)) {
|
||||
const list = comptime blk: {
|
||||
var s: []const u8 = "";
|
||||
for (std.meta.fields(@field(tab_modules, field.name).Action), 0..) |af, i| {
|
||||
s = s ++ (if (i == 0) "" else ", ") ++ af.name;
|
||||
}
|
||||
break :blk s;
|
||||
};
|
||||
return list;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The wording for a binding that `checkScopedBinding` rejected.
|
||||
/// Shared by the TUI startup warning and `zfin doctor` so the two
|
||||
/// cannot describe the same mistake differently.
|
||||
pub fn writeScopedBindingProblem(
|
||||
w: *std.Io.Writer,
|
||||
scope: []const u8,
|
||||
action: []const u8,
|
||||
problem: ScopedBindingCheck,
|
||||
) std.Io.Writer.Error!void {
|
||||
switch (problem) {
|
||||
.ok => {},
|
||||
.unknown_scope => try w.print(
|
||||
"scope '{s}' is not a tab, so the binding for action '{s}' never fires; valid scopes: {s}",
|
||||
.{ scope, action, valid_scope_list },
|
||||
),
|
||||
.unknown_action => {
|
||||
const actions = tabActionList(scope) orelse "";
|
||||
if (actions.len == 0) {
|
||||
try w.print("action '{s}' does nothing: the {s} tab has no local actions", .{ action, scope });
|
||||
} else {
|
||||
try w.print(
|
||||
"action '{s}' is not a {s} tab action, so that key does nothing there; valid: {s}",
|
||||
.{ action, scope, actions },
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one `zfin: keys.srf: ...` line per tab-scoped binding in
|
||||
/// `keymap` that can never fire. Returns how many were written.
|
||||
fn warnScopedBindingProblems(w: *std.Io.Writer, keymap: keybinds.KeyMap) std.Io.Writer.Error!usize {
|
||||
var n: usize = 0;
|
||||
for (keymap.tab_overrides) |to| {
|
||||
for (to.bindings) |b| {
|
||||
const problem = checkScopedBinding(to.scope, b.action_name);
|
||||
if (problem == .ok) continue;
|
||||
try w.writeAll("zfin: keys.srf: ");
|
||||
try writeScopedBindingProblem(w, to.scope, b.action_name, problem);
|
||||
try w.writeAll("\n");
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Comptime lookup table of tab-bar display labels, indexed by
|
||||
/// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed
|
||||
/// from the 1-indexed registry position + the tab module's
|
||||
|
|
@ -946,9 +1049,11 @@ pub const App = struct {
|
|||
}
|
||||
}
|
||||
// Action name didn't resolve; treat as
|
||||
// unbound (silent - keys.srf parsing
|
||||
// already validated the action exists,
|
||||
// future work).
|
||||
// unbound. The loader can't validate
|
||||
// tab-scoped action names, so this is
|
||||
// reported at startup by
|
||||
// `warnScopedBindingProblems` and by
|
||||
// `zfin doctor`, not here per keypress.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -2574,14 +2679,18 @@ pub fn run(
|
|||
defer keymap.deinit();
|
||||
|
||||
// Surface per-record parse warnings (unknown action, malformed
|
||||
// key, etc.) to stderr. Non-fatal - the keymap is otherwise
|
||||
// usable; user just sees that some lines didn't take effect.
|
||||
if (keymap.warnings.len > 0) {
|
||||
// key, etc.) to stderr, plus tab-scoped bindings that name no tab
|
||||
// or no action of that tab - the loader can't check those because
|
||||
// it doesn't know about tab modules. Non-fatal - the keymap is
|
||||
// otherwise usable; user just sees that some lines didn't take
|
||||
// effect.
|
||||
{
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
for (keymap.warnings) |w| {
|
||||
try stderr_writer.interface.print("zfin: {s}\n", .{w});
|
||||
}
|
||||
_ = try warnScopedBindingProblems(&stderr_writer.interface, keymap);
|
||||
try stderr_writer.interface.flush();
|
||||
}
|
||||
|
||||
|
|
@ -3381,3 +3490,116 @@ test "toggleOverlay: same-sym match is case-insensitive" {
|
|||
app.toggleOverlay("vti");
|
||||
try testing.expectEqualStrings("", app.overlay_symbol);
|
||||
}
|
||||
|
||||
// ── checkScopedBinding / warnScopedBindingProblems ──
|
||||
|
||||
test "checkScopedBinding: real tab and action is ok" {
|
||||
try testing.expectEqual(ScopedBindingCheck.ok, checkScopedBinding("options", "expand_collapse"));
|
||||
try testing.expectEqual(ScopedBindingCheck.ok, checkScopedBinding("portfolio", "sort_reverse"));
|
||||
}
|
||||
|
||||
test "checkScopedBinding: misspelled scope and misspelled action" {
|
||||
try testing.expectEqual(ScopedBindingCheck.unknown_scope, checkScopedBinding("optons", "expand_collapse"));
|
||||
try testing.expectEqual(ScopedBindingCheck.unknown_action, checkScopedBinding("options", "expand_colapse"));
|
||||
// An action that exists, but on a different tab.
|
||||
try testing.expectEqual(ScopedBindingCheck.unknown_action, checkScopedBinding("options", "sort_reverse"));
|
||||
}
|
||||
|
||||
test "checkScopedBinding: every registered tab is a valid scope" {
|
||||
// Derived from `tab_modules`, so a new tab can't be forgotten.
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
|
||||
try testing.expect(checkScopedBinding(f.name, "__no_such_action__") != .unknown_scope);
|
||||
}
|
||||
}
|
||||
|
||||
test "writeScopedBindingProblem: wording lists the valid choices" {
|
||||
var buf: [512]u8 = undefined;
|
||||
{
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try writeScopedBindingProblem(&w, "optons", "expand_collapse", .unknown_scope);
|
||||
const msg = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, msg, "scope 'optons' is not a tab") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, msg, "valid scopes: global, portfolio,") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, msg, "options") != null);
|
||||
}
|
||||
{
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try writeScopedBindingProblem(&w, "options", "expand_colapse", .unknown_action);
|
||||
const msg = w.buffered();
|
||||
try testing.expect(std.mem.startsWith(u8, msg, "action 'expand_colapse' is not a options tab action, so that key does nothing there; valid: "));
|
||||
// Every one of the tab's actions is offered - derived from the
|
||||
// enum here too, not a hand-copied list.
|
||||
inline for (std.meta.fields(@field(tab_modules, "options").Action)) |af| {
|
||||
try testing.expect(std.mem.indexOf(u8, msg, af.name) != null);
|
||||
}
|
||||
}
|
||||
// A tab with no local actions at all gets a message that says so,
|
||||
// rather than an empty "valid:" list. Found from the registry rather
|
||||
// than named, so the test can't go stale when a tab gains actions.
|
||||
var checked_empty = false;
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
|
||||
if (comptime std.meta.fields(@field(tab_modules, f.name).Action).len == 0) {
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try writeScopedBindingProblem(&w, f.name, "anything", .unknown_action);
|
||||
try testing.expectEqualStrings(
|
||||
"action 'anything' does nothing: the " ++ f.name ++ " tab has no local actions",
|
||||
w.buffered(),
|
||||
);
|
||||
checked_empty = true;
|
||||
}
|
||||
}
|
||||
try testing.expect(checked_empty);
|
||||
}
|
||||
|
||||
test "warnScopedBindingProblems: reports only the bindings that can't fire" {
|
||||
const km: keybinds.KeyMap = .{
|
||||
.bindings = &.{},
|
||||
.tab_overrides = &.{
|
||||
.{ .scope = "options", .bindings = &.{
|
||||
.{ .action_name = "expand_collapse", .key = .{ .codepoint = 'x' } },
|
||||
.{ .action_name = "expand_colapse", .key = .{ .codepoint = 'y' } },
|
||||
} },
|
||||
.{ .scope = "optons", .bindings = &.{
|
||||
.{ .action_name = "expand_collapse", .key = .{ .codepoint = 'z' } },
|
||||
} },
|
||||
},
|
||||
};
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try testing.expectEqual(@as(usize, 2), try warnScopedBindingProblems(&w, km));
|
||||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "zfin: keys.srf: action 'expand_colapse'") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "zfin: keys.srf: scope 'optons'") != null);
|
||||
// The valid binding is not mentioned.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "'expand_collapse' is not") == null);
|
||||
}
|
||||
|
||||
test "warnScopedBindingProblems: the built-in defaults are clean" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try testing.expectEqual(@as(usize, 0), try warnScopedBindingProblems(&w, keybinds.defaults()));
|
||||
}
|
||||
|
||||
test "writeScopedBindingProblem: every message fits in a lint finding" {
|
||||
// `srf_lint.Finding.describe` truncates at `describe_max`, and these
|
||||
// messages list the valid choices. A tab that grows enough actions
|
||||
// would silently clip its list in `zfin doctor` - this fails first.
|
||||
const srf_lint = @import("srf_lint.zig");
|
||||
// Generous stand-ins for a user's typo.
|
||||
const long_scope = "a_long_misspelled_scope_name_xx";
|
||||
const long_action = "a_long_misspelled_action_name_xxxxxxxx";
|
||||
var buf: [4096]u8 = undefined;
|
||||
{
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try writeScopedBindingProblem(&w, long_scope, long_action, .unknown_scope);
|
||||
try testing.expect(w.buffered().len <= srf_lint.Finding.describe_max);
|
||||
}
|
||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |f| {
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try writeScopedBindingProblem(&w, f.name, long_action, .unknown_action);
|
||||
if (w.buffered().len > srf_lint.Finding.describe_max) {
|
||||
std.debug.print("{s} tab message is {d} bytes, describe_max is {d}\n", .{ f.name, w.buffered().len, srf_lint.Finding.describe_max });
|
||||
return error.MessageTooLong;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ const std = @import("std");
|
|||
const vaxis = @import("vaxis");
|
||||
const srf = @import("srf");
|
||||
const srf_opts = @import("../srf_opts.zig");
|
||||
const srf_lint = @import("../srf_lint.zig");
|
||||
// Cycle-safe: `tui.zig` imports this file too, and Zig resolves the
|
||||
// loop lazily. Needed only by `srf_schema.semanticCheck`, which asks
|
||||
// `tui.checkScopedBinding` because only `tui.zig` knows the tab set.
|
||||
const tui = @import("../tui.zig");
|
||||
|
||||
pub const Action = enum {
|
||||
quit,
|
||||
|
|
@ -427,6 +432,41 @@ const RawRecord = struct {
|
|||
scope: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Schema contract for `keys.srf`. See `srf_lint.validateSchema`.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = RawRecord;
|
||||
pub const file_label: []const u8 = "keys.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/keys-srf.md";
|
||||
|
||||
/// Bindings that load but can never fire. The TUI prints the same
|
||||
/// problems at startup; this puts them in `zfin doctor` with a line
|
||||
/// number. The tab-scoped rule and its wording live in `tui.zig`
|
||||
/// (`checkScopedBinding`, `writeScopedBindingProblem`), so there is
|
||||
/// one definition for both.
|
||||
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
|
||||
_ = ctx;
|
||||
const is_global = rec.scope == null or std.mem.eql(u8, rec.scope.?, "global");
|
||||
if (is_global) {
|
||||
// Same test the loader uses to skip the record.
|
||||
if (parseAction(rec.action) == null) {
|
||||
try sink.addOwned(.semantic, "action", try std.fmt.allocPrint(
|
||||
sink.allocator,
|
||||
"unknown global action '{s}'; binding skipped (`zfin interactive --default-keys` lists them)",
|
||||
.{rec.action},
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const scope = rec.scope.?;
|
||||
const problem = tui.checkScopedBinding(scope, rec.action);
|
||||
if (problem == .ok) return;
|
||||
var aw: std.Io.Writer.Allocating = .init(sink.allocator);
|
||||
errdefer aw.deinit();
|
||||
try tui.writeScopedBindingProblem(&aw.writer, scope, rec.action, problem);
|
||||
try sink.addOwned(.semantic, if (problem == .unknown_scope) "scope" else "action", try aw.toOwnedSlice());
|
||||
}
|
||||
};
|
||||
|
||||
/// Load keybindings from an SRF file. Returns null if the file doesn't exist
|
||||
/// or can't be parsed. On success, the caller owns the returned KeyMap and
|
||||
/// must call deinit().
|
||||
|
|
@ -785,3 +825,44 @@ test "printSectionHeader writes a comment header line" {
|
|||
try printSectionHeader(&aw.writer, "Tab: portfolio");
|
||||
try std.testing.expectEqualStrings("\n# ── Tab: portfolio ──\n", aw.written());
|
||||
}
|
||||
|
||||
// ── srf_schema.semanticCheck ──
|
||||
|
||||
fn lintKeys(data: []const u8) ![]u8 {
|
||||
const allocator = std.testing.allocator;
|
||||
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
|
||||
defer r.deinit();
|
||||
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = @import("../Date.zig").fromYmd(2026, 1, 1) });
|
||||
r.sort();
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
errdefer aw.deinit();
|
||||
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
|
||||
for (r.findings) |f| try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
|
||||
return aw.toOwnedSlice();
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: valid global and scoped bindings are clean" {
|
||||
const got = try lintKeys(
|
||||
\\#!srfv1
|
||||
\\action::quit,key::q
|
||||
\\action::expand_collapse,key::x,scope::options
|
||||
\\action::refresh,key::F5,scope::global
|
||||
\\
|
||||
);
|
||||
defer std.testing.allocator.free(got);
|
||||
try std.testing.expectEqualStrings("", got);
|
||||
}
|
||||
|
||||
test "srf_schema.semanticCheck: misspelled scope, scoped action, and global action" {
|
||||
const got = try lintKeys(
|
||||
\\#!srfv1
|
||||
\\action::expand_collapse,key::x,scope::optons
|
||||
\\action::expand_colapse,key::y,scope::options
|
||||
\\action::quitt,key::Q
|
||||
\\
|
||||
);
|
||||
defer std.testing.allocator.free(got);
|
||||
try std.testing.expect(std.mem.indexOf(u8, got, "2: scope 'optons' is not a tab") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, got, "3: action 'expand_colapse' is not a options tab action") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, got, "4: unknown global action 'quitt'") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1071,10 +1071,10 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
}
|
||||
std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
|
||||
|
||||
// Check if any lots are DRIP
|
||||
// Check if any lots go into the DRIP summary
|
||||
var has_drip = false;
|
||||
for (matching.items) |lot| {
|
||||
if (lot.drip) {
|
||||
if (fmt.isDripSummarized(app.today, lot)) {
|
||||
has_drip = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -1091,9 +1091,9 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
}) catch continue;
|
||||
}
|
||||
} else {
|
||||
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
|
||||
// Has DRIP lots: show the rest individually, summarize held DRIP as ST/LT
|
||||
for (matching.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
if (!fmt.isDripSummarized(app.today, lot)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .lot,
|
||||
.symbol = lot.symbol,
|
||||
|
|
@ -1273,7 +1273,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||
defer cash_lots.deinit(app.allocator);
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
|
||||
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today) and matchesAccountFilter(state, lot.account)) {
|
||||
cash_lots.append(app.allocator, lot) catch continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1302,7 +1302,8 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
});
|
||||
if (state.cash_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash) {
|
||||
// Held lots only, so the rows add up to the total.
|
||||
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .cash_row,
|
||||
.symbol = lot.account orelse "Unknown",
|
||||
|
|
@ -1327,7 +1328,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
});
|
||||
if (state.illiquid_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .illiquid) {
|
||||
if (lot.security_type == .illiquid and lot.lotIsOpenAsOf(app.today)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .illiquid_row,
|
||||
.symbol = lot.symbol,
|
||||
|
|
@ -1867,6 +1868,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
s.unrealized_gain_loss,
|
||||
watch_syms.items,
|
||||
app.portfolio.watchlist_prices,
|
||||
app.today,
|
||||
);
|
||||
} else break :blk views.PositionsWidths{};
|
||||
};
|
||||
|
|
@ -2028,7 +2030,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
// base-ticker price, so a ratio'd lot must go
|
||||
// through `effectivePriceFor` or its Value and
|
||||
// Gain/Loss come out scaled by its ratio.
|
||||
const use_price = views.effectivePriceFor(s.allocations, lot);
|
||||
const use_price = views.effectivePriceFor(s.allocations, lot, app.today);
|
||||
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
|
||||
lot_positive = gl >= 0;
|
||||
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
|
||||
|
|
|
|||
|
|
@ -197,43 +197,17 @@ pub const default_theme = Theme{
|
|||
|
||||
// ── SRF serialization ────────────────────────────────────────
|
||||
|
||||
const field_names = [_]struct { name: []const u8, offset: usize }{
|
||||
.{ .name = "bg", .offset = @offsetOf(Theme, "bg") },
|
||||
.{ .name = "bg_panel", .offset = @offsetOf(Theme, "bg_panel") },
|
||||
.{ .name = "bg_element", .offset = @offsetOf(Theme, "bg_element") },
|
||||
.{ .name = "tab_bg", .offset = @offsetOf(Theme, "tab_bg") },
|
||||
.{ .name = "tab_fg", .offset = @offsetOf(Theme, "tab_fg") },
|
||||
.{ .name = "tab_active_bg", .offset = @offsetOf(Theme, "tab_active_bg") },
|
||||
.{ .name = "tab_active_fg", .offset = @offsetOf(Theme, "tab_active_fg") },
|
||||
.{ .name = "text", .offset = @offsetOf(Theme, "text") },
|
||||
.{ .name = "text_muted", .offset = @offsetOf(Theme, "text_muted") },
|
||||
.{ .name = "text_dim", .offset = @offsetOf(Theme, "text_dim") },
|
||||
.{ .name = "status_bg", .offset = @offsetOf(Theme, "status_bg") },
|
||||
.{ .name = "status_fg", .offset = @offsetOf(Theme, "status_fg") },
|
||||
.{ .name = "input_bg", .offset = @offsetOf(Theme, "input_bg") },
|
||||
.{ .name = "input_fg", .offset = @offsetOf(Theme, "input_fg") },
|
||||
.{ .name = "input_hint", .offset = @offsetOf(Theme, "input_hint") },
|
||||
.{ .name = "accent", .offset = @offsetOf(Theme, "accent") },
|
||||
.{ .name = "positive", .offset = @offsetOf(Theme, "positive") },
|
||||
.{ .name = "negative", .offset = @offsetOf(Theme, "negative") },
|
||||
.{ .name = "warning", .offset = @offsetOf(Theme, "warning") },
|
||||
.{ .name = "info", .offset = @offsetOf(Theme, "info") },
|
||||
.{ .name = "select_bg", .offset = @offsetOf(Theme, "select_bg") },
|
||||
.{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") },
|
||||
.{ .name = "border", .offset = @offsetOf(Theme, "border") },
|
||||
.{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") },
|
||||
/// Schema contract for `theme.srf`. See `srf_lint.validateSchema`.
|
||||
///
|
||||
/// `Record = Theme`: the loader, `printDefaults` and this lint all take
|
||||
/// the key set from `std.meta.fields(Theme)`, so a new color field is
|
||||
/// loadable, printed and linted with no second list to update.
|
||||
pub const srf_schema = struct {
|
||||
pub const Record = Theme;
|
||||
pub const file_label: []const u8 = "theme.srf";
|
||||
pub const doc_path: []const u8 = "reference/config/theme-srf.md";
|
||||
};
|
||||
|
||||
fn colorPtr(theme: *Theme, offset: usize) *Color {
|
||||
const bytes: [*]u8 = @ptrCast(theme);
|
||||
return @ptrCast(@alignCast(bytes + offset));
|
||||
}
|
||||
|
||||
fn colorPtrConst(theme: *const Theme, offset: usize) *const Color {
|
||||
const bytes: [*]const u8 = @ptrCast(theme);
|
||||
return @ptrCast(@alignCast(bytes + offset));
|
||||
}
|
||||
|
||||
fn formatHex(c: Color) [7]u8 {
|
||||
var buf: [7]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch
|
||||
|
|
@ -263,9 +237,10 @@ pub fn printDefaults(io: std.Io) !void {
|
|||
try out.writeAll("#\n");
|
||||
try out.writeAll("# All values are hex RGB: #rrggbb\n");
|
||||
|
||||
for (field_names) |f| {
|
||||
const c = colorPtrConst(&default_theme, f.offset);
|
||||
const hex = formatHex(c.*);
|
||||
// Declaration order of `Theme`, which is also the order the file
|
||||
// has always been written in.
|
||||
inline for (std.meta.fields(Theme)) |f| {
|
||||
const hex = formatHex(@field(default_theme, f.name));
|
||||
try out.print("{s}::{s}\n", .{ f.name, hex });
|
||||
}
|
||||
|
||||
|
|
@ -298,11 +273,10 @@ pub fn loadFromData(data: []const u8) ?Theme {
|
|||
else => continue,
|
||||
};
|
||||
const color = parseHex(str) orelse continue;
|
||||
for (field_names) |f| {
|
||||
if (std.mem.eql(u8, field.key, f.name)) {
|
||||
colorPtr(&theme, f.offset).* = color;
|
||||
break;
|
||||
}
|
||||
// Unknown keys fall through untouched; `zfin doctor`
|
||||
// reports them via `srf_schema`.
|
||||
inline for (std.meta.fields(Theme)) |f| {
|
||||
if (std.mem.eql(u8, field.key, f.name)) @field(theme, f.name) = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -354,3 +328,26 @@ test "default theme has valid colors" {
|
|||
// Text should be bright
|
||||
try std.testing.expect(t.text[0] > 0xc0);
|
||||
}
|
||||
|
||||
test "loadFromData: every Theme field is loadable by its own name" {
|
||||
// Replaces the old hand-maintained name table: a field added to
|
||||
// `Theme` must load from theme.srf with no second list to update.
|
||||
// Each field gets a distinct color, so a key that landed on the
|
||||
// wrong field would show up here too.
|
||||
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||
defer aw.deinit();
|
||||
try aw.writer.writeAll("#!srfv1\n");
|
||||
inline for (std.meta.fields(Theme), 0..) |f, i| {
|
||||
try aw.writer.print("{s}::#0101{x:0>2}\n", .{ f.name, @as(u8, @intCast(i)) });
|
||||
}
|
||||
const t = loadFromData(aw.written()) orelse return error.TestUnexpectedResult;
|
||||
inline for (std.meta.fields(Theme), 0..) |f, i| {
|
||||
try std.testing.expectEqual(Color{ 0x01, 0x01, @intCast(i) }, @field(t, f.name));
|
||||
}
|
||||
}
|
||||
|
||||
test "loadFromData: unknown keys are ignored, not applied to a neighbor" {
|
||||
const t = loadFromData("#!srfv1\nbg_panle::#123456\n") orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(default_theme.bg_panel, t.bg_panel);
|
||||
try std.testing.expectEqual(default_theme.bg, t.bg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ pub fn computeWidths(
|
|||
total_gl: f64,
|
||||
watch_syms: []const []const u8,
|
||||
watch_prices: ?std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) PositionsWidths {
|
||||
var w: PositionsWidths = .{};
|
||||
|
||||
|
|
@ -117,7 +118,7 @@ pub fn computeWidths(
|
|||
if (lot.security_type != .stock) continue;
|
||||
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
|
||||
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
|
||||
const use_price = effectivePriceFor(allocations, lot);
|
||||
const use_price = effectivePriceFor(allocations, lot, as_of);
|
||||
// A ratio'd lot renders its own effective price in the Price
|
||||
// cell (see `hasOwnPrice`), and that price can exceed every
|
||||
// allocation's raw price - an 8.6x institutional ratio puts a
|
||||
|
|
@ -270,10 +271,13 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
|
|||
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
|
||||
/// is answering ONE question: has this allocation been merged?
|
||||
///
|
||||
/// - `close_price` short-circuits first. It is the price the lot
|
||||
/// actually closed at, already in the lot's own terms, so the ratio
|
||||
/// is NOT reapplied. Matches how the contributions pipeline values
|
||||
/// closed lots (`effectivePrice(close_price, true)`).
|
||||
/// - A sold lot's `close_price` short-circuits first
|
||||
/// (`Lot.closePriceAsOf`). It is the price the lot actually closed
|
||||
/// at, already in the lot's own terms, so the ratio is NOT
|
||||
/// reapplied. Matches how the contributions pipeline values closed
|
||||
/// lots (`effectivePrice(close_price, true)`). A `close_price` on a
|
||||
/// lot whose `close_date` hasn't arrived is ignored: that lot is
|
||||
/// still held, and its row says so.
|
||||
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
|
||||
/// carries this lot's own ratio (`positionsAsOf` groups by
|
||||
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
|
||||
|
|
@ -306,8 +310,8 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
|
|||
/// only case a ratio'd alias produces - are exact: with every
|
||||
/// component live off the same raw price `r`, `total_mv / norm_shares`
|
||||
/// reduces to `r`.
|
||||
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
|
||||
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
|
||||
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot, as_of: Date) f64 {
|
||||
if (lot.closePriceAsOf(as_of)) |cp| return lot.effectivePrice(cp, true);
|
||||
for (allocations) |a| {
|
||||
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
|
||||
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
|
||||
|
|
@ -479,12 +483,13 @@ pub const Options = struct {
|
|||
expired_count: usize,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
/// Expired option rows (matured strictly before `as_of`).
|
||||
/// Ended option rows: expired or closed on or before `as_of`
|
||||
/// (`Lot.hasEndedAsOf`).
|
||||
pub fn expiredItems(self: Options) []const Option {
|
||||
return self.items[0..self.expired_count];
|
||||
}
|
||||
|
||||
/// Active option rows (not yet expired, including null-maturity).
|
||||
/// Active option rows (not ended, including null-maturity).
|
||||
pub fn activeItems(self: Options) []const Option {
|
||||
return self.items[self.expired_count..];
|
||||
}
|
||||
|
|
@ -509,12 +514,18 @@ pub const Options = struct {
|
|||
}
|
||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
|
||||
|
||||
// Ended rows first, then active, each in maturity order. A
|
||||
// maturity sort alone no longer yields that prefix: an option
|
||||
// closed early (past `close_date`, future maturity) has ended
|
||||
// but sorts among the active ones.
|
||||
var expired_count: usize = 0;
|
||||
for ([_]bool{ true, false }) |want_ended| {
|
||||
for (tmp.items) |lot| {
|
||||
const is_expired = lot.hasEndedAsOf(as_of);
|
||||
if (is_expired != want_ended) continue;
|
||||
const qty = lot.shares;
|
||||
const cost_per = lot.open_price;
|
||||
const premium = @abs(qty) * cost_per * lot.multiplier;
|
||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
||||
if (is_expired) expired_count += 1;
|
||||
const received = qty < 0;
|
||||
|
||||
|
|
@ -553,6 +564,7 @@ pub const Options = struct {
|
|||
.premium_col_start = OptionsLayout.premium_col_start,
|
||||
});
|
||||
}
|
||||
}
|
||||
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
||||
}
|
||||
|
||||
|
|
@ -607,12 +619,13 @@ pub const CDs = struct {
|
|||
expired_count: usize,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
/// Matured CD rows (maturity strictly before `as_of`).
|
||||
/// Ended CD rows: matured or closed on or before `as_of`
|
||||
/// (`Lot.hasEndedAsOf`).
|
||||
pub fn expiredItems(self: CDs) []const CD {
|
||||
return self.items[0..self.expired_count];
|
||||
}
|
||||
|
||||
/// Active CD rows (not yet matured, including null-maturity).
|
||||
/// Active CD rows (not ended, including null-maturity).
|
||||
pub fn activeItems(self: CDs) []const CD {
|
||||
return self.items[self.expired_count..];
|
||||
}
|
||||
|
|
@ -637,9 +650,12 @@ pub const CDs = struct {
|
|||
}
|
||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
|
||||
|
||||
// Ended rows first, then active - see `Options.init`.
|
||||
var expired_count: usize = 0;
|
||||
for ([_]bool{ true, false }) |want_ended| {
|
||||
for (tmp.items) |lot| {
|
||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
||||
const is_expired = lot.hasEndedAsOf(as_of);
|
||||
if (is_expired != want_ended) continue;
|
||||
if (is_expired) expired_count += 1;
|
||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
||||
|
||||
|
|
@ -671,6 +687,7 @@ pub const CDs = struct {
|
|||
.text = text,
|
||||
});
|
||||
}
|
||||
}
|
||||
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +708,9 @@ const testing = std.testing;
|
|||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
|
||||
/// `as_of` for lot-pricing tests: after every fixture date, so a lot
|
||||
/// with a `close_date` is sold and one without is held.
|
||||
const test_as_of = Date.fromYmd(2026, 6, 1);
|
||||
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
|
|
@ -941,7 +961,7 @@ test "effectivePriceFor: live price gets the lot's ratio applied" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
};
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot, test_as_of), 1e-6);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
||||
|
|
@ -958,7 +978,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 97.50,
|
||||
};
|
||||
const eff_price = effectivePriceFor(&allocs, lot);
|
||||
const eff_price = effectivePriceFor(&allocs, lot, test_as_of);
|
||||
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
|
||||
// The whole point: value must land on the real figure, not the
|
||||
// ratio-skipped one.
|
||||
|
|
@ -969,7 +989,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
|||
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
|
||||
|
|
@ -992,11 +1012,11 @@ test "effectivePriceFor: unmerged live price is already effective, ratio NOT rea
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 106.99,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
// And the lot row must reconcile with its own position row.
|
||||
try testing.expectApproxEqRel(
|
||||
allocs[0].market_value,
|
||||
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
|
||||
lot.effectiveShares() * effectivePriceFor(&allocs, lot, test_as_of),
|
||||
1e-9,
|
||||
);
|
||||
}
|
||||
|
|
@ -1019,7 +1039,7 @@ test "effectivePriceFor: unmerged manual price is also already effective" {
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 18.15,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
|
||||
|
|
@ -1038,7 +1058,7 @@ test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
};
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot, test_as_of), 1e-6);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
|
||||
|
|
@ -1053,25 +1073,46 @@ test "effectivePriceFor: close_price wins over the allocation and skips the rati
|
|||
.shares = 2412.601,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 106.99,
|
||||
.close_date = Date.fromYmd(2026, 5, 1),
|
||||
.close_price = 150.39,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: close_price is ignored until the sale happens" {
|
||||
// A `close_price` with no `close_date` - or one still in the future
|
||||
// - is on a lot that's still held (`Lot.closePriceAsOf`), so its row
|
||||
// is priced live like any other held lot. It used to show the close
|
||||
// price while the status column said "open".
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
var lot = Lot{
|
||||
.symbol = "BENCH",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 50,
|
||||
.close_price = 150.39,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
lot.close_date = Date.fromYmd(2027, 1, 1);
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
// ...and takes over the day it arrives.
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, Date.fromYmd(2027, 1, 1)));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
|
||||
// A CUSIP lot must find its allocation under the ticker alias.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip, test_as_of));
|
||||
var aliased = by_cusip;
|
||||
aliased.ticker = "BENCH";
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased, test_as_of));
|
||||
}
|
||||
|
||||
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
|
||||
|
|
@ -1144,7 +1185,7 @@ test "effectivePriceFor: lot rows sum to their merged position's market value" {
|
|||
var lot_mv_total: f64 = 0;
|
||||
var lot_gl_total: f64 = 0;
|
||||
for (lots) |lot| {
|
||||
const eff_price = effectivePriceFor(summary.allocations, lot);
|
||||
const eff_price = effectivePriceFor(summary.allocations, lot, test_as_of);
|
||||
lot_mv_total += lot.effectiveShares() * eff_price;
|
||||
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
|
||||
}
|
||||
|
|
@ -1199,7 +1240,7 @@ test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position"
|
|||
try testing.expectEqual(ratio, a.price_ratio);
|
||||
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
|
||||
|
||||
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
|
||||
const eff_price = effectivePriceFor(summary.allocations, lots[0], test_as_of);
|
||||
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
|
||||
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
|
||||
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
|
||||
|
|
@ -1275,7 +1316,7 @@ test "computeWidths: a ratio'd lot's effective price widens the Price column" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 100.0,
|
||||
}};
|
||||
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w);
|
||||
}
|
||||
|
||||
|
|
@ -1291,20 +1332,21 @@ test "computeWidths: the Price cell is measured only when the lot occupies it" {
|
|||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 1.0,
|
||||
.close_date = Date.fromYmd(2026, 5, 1),
|
||||
.close_price = 99999.99, // "$99,999.99" = 10 cols
|
||||
}};
|
||||
|
||||
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
|
||||
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
|
||||
|
||||
// Ratio'd: the cell is occupied, so the same price now widens it.
|
||||
lots[0].price_ratio = 2.0;
|
||||
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
|
||||
}
|
||||
|
||||
test "computeWidths: empty portfolio sits at the header-label floors" {
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
|
||||
|
|
@ -1321,7 +1363,7 @@ test "computeWidths: a small portfolio stays at the floors (tightens, no waste)"
|
|||
// at its minimum. This is the "don't waste space" half of
|
||||
// fit-to-content.
|
||||
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
|
||||
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
|
||||
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
|
||||
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
|
||||
|
|
@ -1333,7 +1375,7 @@ test "computeWidths: large crypto-scale values grow each column" {
|
|||
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
|
||||
// / +$5,000,000.00(14, sign + $5,000,000.00).
|
||||
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
|
||||
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 8), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 7), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
|
||||
|
|
@ -1352,10 +1394,10 @@ test "computeWidths: a single lot's gain/loss can exceed the netted position" {
|
|||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
|
||||
};
|
||||
// Allocations only: stays at the 9-col floor.
|
||||
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
|
||||
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
|
||||
// With lots: grows to fit the per-lot swing.
|
||||
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
|
||||
}
|
||||
|
||||
|
|
@ -1368,13 +1410,13 @@ test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tol
|
|||
// Closed stock lot: market value / gain-loss use close_price
|
||||
// (1234), not the allocation's current price (60). gl =
|
||||
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 1234.0 },
|
||||
// Stock lot whose symbol has no matching allocation and no
|
||||
// close_price: currentPriceFor falls back to 0 (exercises the
|
||||
// not-found path), contributing nothing.
|
||||
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
|
||||
};
|
||||
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of);
|
||||
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
|
||||
// 999999.0 = 8-col share count was ignored).
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w);
|
||||
|
|
@ -1391,7 +1433,7 @@ test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
|
|||
// "NOPRICE" has no map entry, so it widens Symbol but not Price
|
||||
// (exercises the price-absent branch).
|
||||
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 11), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 9), w.price_w);
|
||||
}
|
||||
|
|
@ -1430,7 +1472,58 @@ test "computeWidths: lot columns size to effective (split-adjusted) shares" {
|
|||
};
|
||||
const no_allocs: []const Allocation = &.{};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
|
||||
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null, test_as_of);
|
||||
try testing.expectEqual(sharesCols(1000.0), w.shares_w);
|
||||
try testing.expect(w.shares_w >= sharesCols(100.0));
|
||||
}
|
||||
|
||||
test "Options.init: an option on its maturity day has ended" {
|
||||
// End-of-day semantics, the same as the summary totals
|
||||
// (`Lot.hasEndedAsOf`). This section used `maturity < as_of` and
|
||||
// showed it active - and in its TOTAL - for that one day.
|
||||
const as_of = Date.fromYmd(2024, 6, 21);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "AAA 2024-06-21 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 6, 21) },
|
||||
};
|
||||
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
||||
defer opts.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
|
||||
try testing.expectEqual(@as(usize, 0), opts.activeItems().len);
|
||||
}
|
||||
|
||||
test "Options.init: an option closed before maturity has ended, and ended rows stay a prefix" {
|
||||
// Closed early: past close_date, future maturity. It sorts among the
|
||||
// active rows by maturity, so the partition - not the sort - has to
|
||||
// move it into the ended prefix, or `activeItems` (and the section
|
||||
// TOTAL built from it) would still include it.
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "AAA 2024-09-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 9, 1) },
|
||||
.{ .symbol = "BBB 2024-12-01 C50", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1), .close_date = Date.fromYmd(2024, 3, 1), .close_price = 0.10 },
|
||||
.{ .symbol = "CCC 2025-03-01 C75", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option, .maturity_date = Date.fromYmd(2025, 3, 1) },
|
||||
};
|
||||
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
||||
defer opts.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), opts.expired_count);
|
||||
try testing.expectEqualStrings("BBB 2024-12-01 C50", opts.expiredItems()[0].lot.symbol);
|
||||
try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
|
||||
// Active rows keep their maturity order.
|
||||
try testing.expectEqualStrings("AAA 2024-09-01 C100", opts.activeItems()[0].lot.symbol);
|
||||
try testing.expectEqualStrings("CCC 2025-03-01 C75", opts.activeItems()[1].lot.symbol);
|
||||
for (opts.items[0..opts.expired_count]) |o| try testing.expect(o.is_expired);
|
||||
for (opts.items[opts.expired_count..]) |o| try testing.expect(!o.is_expired);
|
||||
}
|
||||
|
||||
test "CDs.init: a CD closed before maturity has ended; one maturing today has too" {
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "CD-TODAY", .shares = 5000, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 6, 1) },
|
||||
.{ .symbol = "CD-BROKEN", .shares = 8000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .close_date = Date.fromYmd(2024, 4, 1) },
|
||||
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 6, 1) },
|
||||
};
|
||||
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
|
||||
defer cds.deinit();
|
||||
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
|
||||
try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
|
||||
try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue