include observations in review tab

This commit is contained in:
Emil Lerch 2026-06-09 13:27:16 -07:00
parent 4ed3b91fce
commit 474d288c4c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 1845 additions and 179 deletions

View file

@ -13,11 +13,28 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const review_view = @import("../views/review.zig");
const observations_view = @import("../views/observations_view.zig");
const observations = @import("../analytics/observations.zig");
const Journal = @import("../data/Journal.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
pub const ParsedArgs = struct {
sort: ?review_view.SortField = null,
sort_dir: review_view.SortDirection = .desc,
/// Whether to render acknowledged findings in the findings
/// table. Default false (active findings only).
show_acked: bool = false,
/// Which observation checks to run + display. `.all` runs every
/// registered check; `.fast` runs only short-running ones (none
/// in M2 every check is fast). `.none` skips the engine
/// entirely (don't render the findings section).
checks: ChecksMode = .all,
};
pub const ChecksMode = enum {
all,
fast,
none,
};
pub const meta: framework.Meta = .{
@ -44,6 +61,11 @@ pub const meta: framework.Meta = .{
\\ 5y-maxdd
\\ --asc Sort ascending (default: descending for
\\ numeric fields, ascending for symbol/sector)
\\ --checks=MODE Observation engine mode: all (default),
\\ fast (skip long-running checks), none
\\ (suppress findings section).
\\ --show-acked Include already-acknowledged findings
\\ in the findings table.
\\
\\Reads classifications from `metadata.srf` and account tax types
\\from `accounts.srf`. Tax% is the share of each holding's market
@ -51,7 +73,7 @@ pub const meta: framework.Meta = .{
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, InvalidSortField },
.user_errors = error{ UnexpectedArg, InvalidSortField, InvalidChecksMode },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
@ -73,6 +95,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
parsed.sort_dir = .asc;
} else if (std.mem.eql(u8, arg, "--desc")) {
parsed.sort_dir = .desc;
} else if (std.mem.eql(u8, arg, "--show-acked")) {
parsed.show_acked = true;
} else if (std.mem.startsWith(u8, arg, "--checks=")) {
const value = arg["--checks=".len..];
parsed.checks = parseChecksMode(value) orelse {
cli.stderrPrint(ctx.io, "Error: --checks must be one of: all, fast, none\n");
return error.InvalidChecksMode;
};
} else {
cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n");
return error.UnexpectedArg;
@ -81,6 +111,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
return parsed;
}
fn parseChecksMode(s: []const u8) ?ChecksMode {
if (std.mem.eql(u8, s, "all")) return .all;
if (std.mem.eql(u8, s, "fast")) return .fast;
if (std.mem.eql(u8, s, "none")) return .none;
return null;
}
/// Comma-joined valid sort fields for the user-facing error message.
/// Built once at comptime so we don't allocate at error time.
const joined_sort_fields: []const u8 = blk: {
@ -173,6 +210,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var view = try review_view.buildReview(
allocator,
io,
pf_data.summary,
&pf_data.candle_map,
&dividend_map,
@ -191,7 +229,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
review_view.sortGroupedByDefault(view.rows);
}
try render(out, color, view);
try render(allocator, io, out, color, view, anchor_path, parsed);
}
// Rendering
@ -227,7 +265,15 @@ const col_widths = [_]usize{
col_tax,
};
fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void {
fn render(
allocator: std.mem.Allocator,
io: std.Io,
out: *std.Io.Writer,
color: bool,
view: review_view.ReviewView,
anchor_path: []const u8,
parsed: ParsedArgs,
) !void {
try cli.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" As of {f} Liquid: {f} Holdings: {d}\n\n", .{
@ -235,6 +281,14 @@ fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void
});
try cli.reset(out, color);
// Status grid: per-check pass/warn/flag glyphs across the top.
// Mirrors the TUI review tab so the "at a glance" experience
// matches when the user pipes the CLI output.
if (view.observations) |panel| {
try renderStatusGrid(out, color, panel);
try out.print("\n", .{});
}
// Header row. Column order matches `col_widths`.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
@ -274,9 +328,164 @@ fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void
try cli.reset(out, color);
}
// Findings section. Render unless `--checks=none` was passed.
if (parsed.checks != .none) {
try renderFindings(allocator, io, out, color, &view, anchor_path, parsed.show_acked);
}
try out.print("\n", .{});
}
/// Render the per-check status grid to stdout. Layout mirrors
/// the TUI's `appendStatusGrid`: 3 cells per row, each cell
/// "<right-padded label> <glyph>". Color promoted to the row's
/// worst severity so multi-cell rows still draw the user's eye
/// to the bad ones.
fn renderStatusGrid(
out: *std.Io.Writer,
color: bool,
panel: observations.CheckPanel,
) !void {
if (panel.pending.len == 0) return;
const status_label_cols: usize = 22;
const cells_per_row: usize = 3;
var i: usize = 0;
while (i < panel.pending.len) {
const end = @min(i + cells_per_row, panel.pending.len);
// Row color = worst severity in the row.
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
var worst_color: [3]u8 = cli.CLR_MUTED;
for (panel.pending[i..end]) |pc| {
const result = pc.state.complete;
const rank: u8 = switch (result) {
.pass, .skipped => 0,
.warn => 1,
.flag, .err => 2,
};
if (rank > worst) {
worst = rank;
worst_color = switch (result) {
.warn => cli.CLR_WARNING,
.flag, .err => cli.CLR_NEGATIVE,
else => cli.CLR_MUTED,
};
}
}
try cli.setFg(out, color, worst_color);
try out.print(" ", .{});
for (panel.pending[i..end], 0..) |pc, col| {
if (col > 0) try out.print(" ", .{});
const label = pc.check.label;
const lbl_cols = label.len; // ASCII labels: byte count == display cols
// Right-pad label.
if (lbl_cols < status_label_cols) {
var k: usize = 0;
while (k < status_label_cols - lbl_cols) : (k += 1) try out.print(" ", .{});
}
try out.print("{s} ", .{label});
const glyph: []const u8 = switch (pc.state.complete) {
.pass => "\u{FE0F}",
.warn => "⚠️",
.flag => "\u{FE0F}",
.skipped => "\u{FE0F}",
.err => "🛑\u{FE0F}",
};
try out.print("{s}", .{glyph});
}
try out.print("\n", .{});
try cli.reset(out, color);
i = end;
}
}
/// Render the findings section to stdout. Loads the journal from
/// the portfolio's directory (missing empty), joins with the
/// observation panel via `observations_view.build`, and writes a
/// styled findings table similar to the TUI's. The CLI is read-only
/// acks must come from the TUI.
fn renderFindings(
allocator: std.mem.Allocator,
io: std.Io,
out: *std.Io.Writer,
color: bool,
view: *const review_view.ReviewView,
anchor_path: []const u8,
show_acked: bool,
) !void {
const panel = if (view.observations) |*p| p else return;
// Load the journal. Missing file empty journal (first run).
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
defer allocator.free(journal_path);
var j = Journal.load(allocator, io, journal_path) catch |err| blk: {
cli.stderrPrint(io, "Warning: ");
cli.stderrPrint(io, journal_path);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, " — proceeding with empty journal.\n");
const empty = try allocator.alloc(Journal.Entry, 0);
break :blk Journal{ .allocator = allocator, .entries = empty };
};
defer j.deinit();
var fv = try observations_view.build(allocator, panel, &j, show_acked);
defer fv.deinit(allocator);
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Findings ({d} active, {d} acked, {d} resolved)", .{
fv.total_active,
fv.total_acked,
fv.total_resolved,
});
if (show_acked) try out.print(" [showing acked]", .{});
try out.print("\n", .{});
try cli.reset(out, color);
try writeSeparator(out, color);
if (fv.rows.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
if (fv.total_acked > 0 and !show_acked) {
try out.print(" No active findings. Use --show-acked to see acknowledged.\n", .{});
} else {
try out.print(" No findings.\n", .{});
}
try cli.reset(out, color);
return;
}
for (fv.rows) |row| {
const glyph: []const u8 = switch (row.severity) {
.warn => "⚠️",
.flag => "\u{FE0F}",
.err => "🛑\u{FE0F}",
};
const ansi: [3]u8 = if (row.is_acked)
cli.CLR_MUTED
else switch (row.severity) {
.warn => cli.CLR_WARNING,
.flag, .err => cli.CLR_NEGATIVE,
};
try cli.setFg(out, color, ansi);
try out.print(" {s} {s}{s}\n", .{
glyph,
if (row.is_acked) "[acked] " else "",
row.text,
});
try cli.reset(out, color);
}
}
fn writeSeparator(out: *std.Io.Writer, color: bool) !void {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
@ -475,6 +684,44 @@ test "parseArgs: positional arg errors" {
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --show-acked sets the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--show-acked"};
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.show_acked);
}
test "parseArgs: --checks=fast sets ChecksMode.fast" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=fast"};
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(ChecksMode.fast, parsed.checks);
}
test "parseArgs: --checks=none sets ChecksMode.none" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=none"};
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(ChecksMode.none, parsed.checks);
}
test "parseArgs: --checks=BOGUS errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=bogus"};
try testing.expectError(error.InvalidChecksMode, parseArgs(&ctx, &args));
}
test "parseChecksMode: covers all variants" {
try testing.expectEqual(ChecksMode.all, parseChecksMode("all").?);
try testing.expectEqual(ChecksMode.fast, parseChecksMode("fast").?);
try testing.expectEqual(ChecksMode.none, parseChecksMode("none").?);
try testing.expect(parseChecksMode("nope") == null);
}
test "joinSortFields: contains all field names" {
const joined = joinSortFields();
for (review_view.sort_field_names) |name| {
@ -711,7 +958,7 @@ test "render: emits header, separator, rows, and totals" {
.total_liquid = 1_000_000.0,
.portfolio_path = "test_portfolio.srf",
};
try render(&w, false, view);
try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null);
try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != null);
@ -748,7 +995,7 @@ test "render: emits reweight footnote when any flag set" {
.total_liquid = 0,
.portfolio_path = "x.srf",
};
try render(&w, false, view);
try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
}

View file

@ -55,11 +55,10 @@
//! ## Lifecycle
//!
//! - **Read:** single-pass iterator over the file. Acks push a new
//! `JournalEntry`; notes append to the last entry. Orphan note
//! `Entry`; notes append to the last entry. Orphan note
//! `error.OrphanedNote`.
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
//! atomic-rewrite the file (temp file + rename). Always rewriting keeps
//! the file clean; git tracks history.
//! atomic-rewrite the file via `atomic.writeFileAtomic`.
//! - **Concurrency:** the file is per-portfolio (sibling of
//! `portfolio.srf`); concurrent zfin invocations on the same portfolio
//! would race, but that's the existing convention for every sibling
@ -70,6 +69,11 @@ const srf = @import("srf");
const Date = @import("../Date.zig");
const atomic = @import("../atomic.zig");
const Journal = @This();
allocator: std.mem.Allocator,
entries: []Entry,
pub const State = enum {
active,
acknowledged,
@ -89,7 +93,7 @@ pub const Acknowledgment = struct {
/// Wire-format note record: just a single line of text. Used only by
/// the SRF parser/formatter; the rest of the codebase sees notes as
/// `[]const u8` slices on `JournalEntry.notes`.
/// `[]const u8` slices on `Entry.notes`.
const NoteRecord = struct {
line: []const u8,
};
@ -102,10 +106,9 @@ const JournalRecord = union(enum) {
note: NoteRecord,
};
/// In-memory ack with its notes already grouped. Built by
/// `Journal.load`; consumed by callers that want "the ack and its
/// reasoning together."
pub const JournalEntry = struct {
/// 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 {
ack: Acknowledgment,
/// Note fragments in the order the user entered them. Each is an
/// allocator-owned slice; freed by `Journal.deinit`.
@ -113,46 +116,40 @@ pub const JournalEntry = struct {
/// Concatenate the notes with newlines into a single string.
/// Allocator-owned; caller frees.
pub fn fullNote(self: JournalEntry, allocator: std.mem.Allocator) ![]u8 {
pub fn fullNote(self: Entry, allocator: std.mem.Allocator) ![]u8 {
return try std.mem.join(allocator, "\n", self.notes);
}
};
/// In-memory journal state. Owns all string data and the entries slice.
pub const Journal = struct {
allocator: std.mem.Allocator,
entries: []JournalEntry,
pub fn deinit(self: *Journal) void {
const a = self.allocator;
for (self.entries) |entry| {
a.free(entry.ack.observation);
a.free(entry.ack.target);
for (entry.notes) |line| a.free(line);
a.free(entry.notes);
}
a.free(self.entries);
self.* = undefined;
pub fn deinit(self: *Journal) void {
const a = self.allocator;
for (self.entries) |entry| {
a.free(entry.ack.observation);
a.free(entry.ack.target);
for (entry.notes) |line| a.free(line);
a.free(entry.notes);
}
a.free(self.entries);
self.* = undefined;
}
/// Find the entry matching `(observation, target)`. Returns null
/// if not found. There's only ever one entry per pair (we don't
/// preserve transition history), so no tiebreaker is needed.
pub fn findByTarget(
self: *const Journal,
observation: []const u8,
target: []const u8,
) ?*const JournalEntry {
for (self.entries) |*e| {
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
if (!std.mem.eql(u8, e.ack.target, target)) continue;
return e;
}
return null;
/// Find the entry matching `(observation, target)`. Returns null if
/// not found. There's only ever one entry per pair (we don't
/// preserve transition history), so no tiebreaker is needed.
pub fn findByTarget(
self: *const Journal,
observation: []const u8,
target: []const u8,
) ?*const Entry {
for (self.entries) |*e| {
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
if (!std.mem.eql(u8, e.ack.target, target)) continue;
return e;
}
};
return null;
}
/// Load and parse a journal file. Returns an empty `Journal` when the
/// Load and parse a journal file. Returns an empty journal when the
/// file doesn't exist (first-time use case). `path` should be an
/// absolute or cwd-relative path to `acknowledgments.srf`.
pub fn load(
@ -162,7 +159,7 @@ pub fn load(
) !Journal {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) {
error.FileNotFound => {
return .{ .allocator = allocator, .entries = try allocator.alloc(JournalEntry, 0) };
return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0) };
},
else => return err,
};
@ -191,17 +188,17 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
if (data.len == 0) {
return .{
.allocator = allocator,
.entries = try allocator.alloc(JournalEntry, 0),
.entries = try allocator.alloc(Entry, 0),
};
}
var entries = std.ArrayList(JournalEntry).empty;
var entries = std.ArrayList(Entry).empty;
errdefer freeEntries(allocator, &entries);
// Per-entry notes lists. Lives parallel to `entries` and is
// converted to owned slices at the end. We use a separate list
// (instead of mutating each entry's `notes` field as we go)
// because `JournalEntry.notes` is `[]const []const u8` a const
// because `Entry.notes` is `[]const []const u8` a const
// slice so we can't append to it after the entry is created.
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
errdefer {
@ -256,7 +253,7 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
}
/// Free a partially-built entries list (for parse errdefer).
fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntry)) void {
fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry)) void {
for (entries.items) |entry| {
allocator.free(entry.ack.observation);
allocator.free(entry.ack.target);
@ -274,13 +271,13 @@ fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntr
/// entered (one Enter press per fragment in the TUI's note input).
/// Pass an empty slice to record an ack with no reasoning.
pub fn append(
journal: *Journal,
self: *Journal,
io: std.Io,
path: []const u8,
new_ack: Acknowledgment,
note_fragments: []const []const u8,
) !void {
const a = journal.allocator;
const a = self.allocator;
// Build the new entry's owned strings first. errdefer cleanup
// is fiddly because we need to roll back partial allocations
@ -300,10 +297,10 @@ pub fn append(
}
// Grow the entries slice by one and append.
var new_entries = try a.alloc(JournalEntry, journal.entries.len + 1);
var new_entries = try a.alloc(Entry, self.entries.len + 1);
errdefer a.free(new_entries);
@memcpy(new_entries[0..journal.entries.len], journal.entries);
new_entries[journal.entries.len] = .{
@memcpy(new_entries[0..self.entries.len], self.entries);
new_entries[self.entries.len] = .{
.ack = .{
.observation = owned_obs,
.target = owned_target,
@ -317,10 +314,10 @@ pub fn append(
// Replace the slice WITHOUT freeing the old strings they're
// shallow-copied into new_entries above. Just free the old slice.
a.free(journal.entries);
journal.entries = new_entries;
a.free(self.entries);
self.entries = new_entries;
try writeFile(journal, io, path);
try writeFile(self, io, path);
}
/// Update the state of an existing acknowledgment, set the relevant
@ -335,7 +332,7 @@ pub fn append(
/// Returns `error.AckNotFound` if no entry matches `(observation,
/// target)`.
pub fn setState(
journal: *Journal,
self: *Journal,
io: std.Io,
path: []const u8,
observation: []const u8,
@ -343,8 +340,8 @@ pub fn setState(
new_state: State,
today: Date,
) !void {
var found: ?*JournalEntry = null;
for (journal.entries) |*e| {
var found: ?*Entry = null;
for (self.entries) |*e| {
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
if (!std.mem.eql(u8, e.ack.target, target)) continue;
found = e;
@ -366,14 +363,14 @@ pub fn setState(
}
entry.ack.state = new_state;
try writeFile(journal, io, path);
try writeFile(self, io, path);
}
/// Atomic file write: serialize all records, then hand to
/// `atomic.writeFileAtomic` which writes to `<path>.tmp`, fsyncs,
/// and renames. Crash-safe.
fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void {
const a = journal.allocator;
fn writeFile(self: *const Journal, io: std.Io, path: []const u8) !void {
const a = self.allocator;
// Build the file contents in memory. For a journal of typical
// size (dozens to low hundreds of records) this is trivially
@ -385,12 +382,12 @@ fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void {
// `#!srfv1` directive header once at the top and emits every
// record through its native formatter. Ack records are followed
// immediately by their note records, in entry-list order.
var total_records: usize = journal.entries.len;
for (journal.entries) |e| total_records += e.notes.len;
var total_records: usize = self.entries.len;
for (self.entries) |e| total_records += e.notes.len;
var records = try a.alloc(JournalRecord, total_records);
defer a.free(records);
var ri: usize = 0;
for (journal.entries) |e| {
for (self.entries) |e| {
records[ri] = .{ .acknowledgment = e.ack };
ri += 1;
for (e.notes) |line| {
@ -409,9 +406,9 @@ fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void {
const testing = std.testing;
test "parse: empty input produces empty journal" {
var j = try parse(testing.allocator, "");
defer j.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len);
var journal = try parse(testing.allocator, "");
defer journal.deinit();
try testing.expectEqual(@as(usize, 0), journal.entries.len);
}
test "parse: single ack with two notes round-trips" {
@ -421,10 +418,10 @@ test "parse: single ack with two notes round-trips" {
\\type::note,line::Holding through earnings cycle.
\\type::note,line::Will trim by Q3 2026.
;
var j = try parse(testing.allocator, data);
defer j.deinit();
try testing.expectEqual(@as(usize, 1), j.entries.len);
const entry = j.entries[0];
var journal = try parse(testing.allocator, data);
defer journal.deinit();
try testing.expectEqual(@as(usize, 1), journal.entries.len);
const entry = journal.entries[0];
try testing.expectEqualStrings("position_concentration", entry.ack.observation);
try testing.expectEqualStrings("NVDA", entry.ack.target);
try testing.expectEqual(State.acknowledged, entry.ack.state);
@ -442,14 +439,14 @@ test "parse: notes attach to the most-recent preceding ack" {
\\type::note,line::for B
\\type::note,line::also for B
;
var j = try parse(testing.allocator, data);
defer j.deinit();
try testing.expectEqual(@as(usize, 2), j.entries.len);
try testing.expectEqual(@as(usize, 1), j.entries[0].notes.len);
try testing.expectEqualStrings("for A", j.entries[0].notes[0]);
try testing.expectEqual(@as(usize, 2), j.entries[1].notes.len);
try testing.expectEqualStrings("for B", j.entries[1].notes[0]);
try testing.expectEqualStrings("also for B", j.entries[1].notes[1]);
var journal = try parse(testing.allocator, data);
defer journal.deinit();
try testing.expectEqual(@as(usize, 2), journal.entries.len);
try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
try testing.expectEqualStrings("for A", journal.entries[0].notes[0]);
try testing.expectEqual(@as(usize, 2), journal.entries[1].notes.len);
try testing.expectEqualStrings("for B", journal.entries[1].notes[0]);
try testing.expectEqualStrings("also for B", journal.entries[1].notes[1]);
}
test "parse: orphan note before any ack returns error.OrphanedNote" {
@ -467,17 +464,17 @@ test "findByTarget: finds matching, returns null for non-match" {
\\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged
\\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged
;
var j = try parse(testing.allocator, data);
defer j.deinit();
var journal = try parse(testing.allocator, data);
defer journal.deinit();
const found = j.findByTarget("position_concentration", "NVDA").?;
const found = journal.findByTarget("position_concentration", "NVDA").?;
try testing.expectEqualStrings("NVDA", found.ack.target);
const not_found = j.findByTarget("position_concentration", "AAPL");
const not_found = journal.findByTarget("position_concentration", "AAPL");
try testing.expect(not_found == null);
}
test "JournalEntry.fullNote: joins fragments with newlines" {
test "Entry.fullNote: joins fragments with newlines" {
const data =
\\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
@ -485,21 +482,21 @@ test "JournalEntry.fullNote: joins fragments with newlines" {
\\type::note,line::second
\\type::note,line::third
;
var j = try parse(testing.allocator, data);
defer j.deinit();
const full = try j.entries[0].fullNote(testing.allocator);
var journal = try parse(testing.allocator, data);
defer journal.deinit();
const full = try journal.entries[0].fullNote(testing.allocator);
defer testing.allocator.free(full);
try testing.expectEqualStrings("first\nsecond\nthird", full);
}
test "JournalEntry.fullNote: empty notes returns empty string" {
test "Entry.fullNote: empty notes returns empty string" {
const data =
\\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
;
var j = try parse(testing.allocator, data);
defer j.deinit();
const full = try j.entries[0].fullNote(testing.allocator);
var journal = try parse(testing.allocator, data);
defer journal.deinit();
const full = try journal.entries[0].fullNote(testing.allocator);
defer testing.allocator.free(full);
try testing.expectEqualStrings("", full);
}
@ -509,10 +506,10 @@ test "parse: optional unacknowledged_at and resolved_at fields work when omitted
\\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
;
var j = try parse(testing.allocator, data);
defer j.deinit();
try testing.expect(j.entries[0].ack.unacknowledged_at == null);
try testing.expect(j.entries[0].ack.resolved_at == null);
var journal = try parse(testing.allocator, data);
defer journal.deinit();
try testing.expect(journal.entries[0].ack.unacknowledged_at == null);
try testing.expect(journal.entries[0].ack.resolved_at == null);
}
test "parse: optional unacknowledged_at and resolved_at fields work when set" {
@ -520,10 +517,10 @@ test "parse: optional unacknowledged_at and resolved_at fields work when set" {
\\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01
;
var j = try parse(testing.allocator, data);
defer j.deinit();
try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, j.entries[0].ack.unacknowledged_at.?.days);
try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, j.entries[0].ack.resolved_at.?.days);
var journal = try parse(testing.allocator, data);
defer journal.deinit();
try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, journal.entries[0].ack.unacknowledged_at.?.days);
try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, journal.entries[0].ack.resolved_at.?.days);
}
test "parse: malformed record returns parse error" {
@ -554,9 +551,9 @@ test "load: missing file returns empty journal" {
const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len);
var journal = try load(allocator, io, path);
defer journal.deinit();
try testing.expectEqual(@as(usize, 0), journal.entries.len);
}
test "append + load round-trip: ack with two notes" {
@ -570,27 +567,27 @@ test "append + load round-trip: ack with two notes" {
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len);
var journal = try load(allocator, io, path);
defer journal.deinit();
try testing.expectEqual(@as(usize, 0), journal.entries.len);
const fragments = [_][]const u8{ "first thought", "follow-up rationale" };
try append(&j, io, path, .{
try journal.append(io, path, .{
.observation = "position_concentration",
.target = "NVDA",
.acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged,
}, &fragments);
var j2 = try load(allocator, io, path);
defer j2.deinit();
try testing.expectEqual(@as(usize, 1), j2.entries.len);
try testing.expectEqualStrings("position_concentration", j2.entries[0].ack.observation);
try testing.expectEqualStrings("NVDA", j2.entries[0].ack.target);
try testing.expectEqual(State.acknowledged, j2.entries[0].ack.state);
try testing.expectEqual(@as(usize, 2), j2.entries[0].notes.len);
try testing.expectEqualStrings("first thought", j2.entries[0].notes[0]);
try testing.expectEqualStrings("follow-up rationale", j2.entries[0].notes[1]);
var reloaded = try load(allocator, io, path);
defer reloaded.deinit();
try testing.expectEqual(@as(usize, 1), reloaded.entries.len);
try testing.expectEqualStrings("position_concentration", reloaded.entries[0].ack.observation);
try testing.expectEqualStrings("NVDA", reloaded.entries[0].ack.target);
try testing.expectEqual(State.acknowledged, reloaded.entries[0].ack.state);
try testing.expectEqual(@as(usize, 2), reloaded.entries[0].notes.len);
try testing.expectEqualStrings("first thought", reloaded.entries[0].notes[0]);
try testing.expectEqualStrings("follow-up rationale", reloaded.entries[0].notes[1]);
}
test "append: two acks land in append-order on reload" {
@ -604,27 +601,27 @@ test "append: two acks land in append-order on reload" {
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
var journal = try load(allocator, io, path);
defer journal.deinit();
try append(&j, io, path, .{
try journal.append(io, path, .{
.observation = "k",
.target = "B",
.acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged,
}, &.{});
try append(&j, io, path, .{
try journal.append(io, path, .{
.observation = "k",
.target = "A",
.acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged,
}, &.{});
var j2 = try load(allocator, io, path);
defer j2.deinit();
try testing.expectEqual(@as(usize, 2), j2.entries.len);
try testing.expectEqualStrings("B", j2.entries[0].ack.target);
try testing.expectEqualStrings("A", j2.entries[1].ack.target);
var reloaded = try load(allocator, io, path);
defer reloaded.deinit();
try testing.expectEqual(@as(usize, 2), reloaded.entries.len);
try testing.expectEqualStrings("B", reloaded.entries[0].ack.target);
try testing.expectEqualStrings("A", reloaded.entries[1].ack.target);
}
test "setState: acknowledged → active sets unacknowledged_at" {
@ -638,23 +635,23 @@ test "setState: acknowledged → active sets unacknowledged_at" {
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
var journal = try load(allocator, io, path);
defer journal.deinit();
try append(&j, io, path, .{
try journal.append(io, path, .{
.observation = "k",
.target = "X",
.acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged,
}, &.{});
try setState(&j, io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9));
try journal.setState(io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9));
var j2 = try load(allocator, io, path);
defer j2.deinit();
try testing.expectEqual(State.active, j2.entries[0].ack.state);
try testing.expect(j2.entries[0].ack.unacknowledged_at != null);
try testing.expect(j2.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9)));
var reloaded = try load(allocator, io, path);
defer reloaded.deinit();
try testing.expectEqual(State.active, reloaded.entries[0].ack.state);
try testing.expect(reloaded.entries[0].ack.unacknowledged_at != null);
try testing.expect(reloaded.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9)));
}
test "setState: missing target returns AckNotFound" {
@ -668,12 +665,12 @@ test "setState: missing target returns AckNotFound" {
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
var journal = try load(allocator, io, path);
defer journal.deinit();
try testing.expectError(
error.AckNotFound,
setState(&j, io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)),
journal.setState(io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)),
);
}
@ -688,21 +685,21 @@ test "setState: → resolved sets resolved_at" {
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path);
var j = try load(allocator, io, path);
defer j.deinit();
var journal = try load(allocator, io, path);
defer journal.deinit();
try append(&j, io, path, .{
try journal.append(io, path, .{
.observation = "k",
.target = "X",
.acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged,
}, &.{});
try setState(&j, io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10));
try journal.setState(io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10));
var j2 = try load(allocator, io, path);
defer j2.deinit();
try testing.expectEqual(State.resolved, j2.entries[0].ack.state);
try testing.expect(j2.entries[0].ack.resolved_at != null);
try testing.expect(j2.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10)));
var reloaded = try load(allocator, io, path);
defer reloaded.deinit();
try testing.expectEqual(State.resolved, reloaded.entries[0].ack.state);
try testing.expect(reloaded.entries[0].ack.resolved_at != null);
try testing.expect(reloaded.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10)));
}

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,11 @@
//! - The **engine** (`analytics/observations.zig`) produces a
//! `CheckPanel` whose `pending` slice has one entry per registered
//! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`.
//! - The **journal** (`data/journal.zig`) holds the user's
//! - The **journal** (`data/Journal.zig`) holds the user's
//! acknowledgments durable records keyed by `(observation, target)`.
//! - This **view** matches the two: every `Observation` in a
//! `warn`/`flag` result becomes a `FindingRow`. The row is marked
//! acked iff a `JournalEntry` exists with `state == .acknowledged`
//! acked iff a `Journal.Entry` exists with `state == .acknowledged`
//! and matching `(observation, target)`.
//!
//! ## Lifetime
@ -32,13 +32,11 @@
const std = @import("std");
const observations = @import("../analytics/observations.zig");
const journal_mod = @import("../data/journal.zig");
const Journal = @import("../data/Journal.zig");
const Observation = observations.Observation;
const Severity = observations.Severity;
const CheckPanel = observations.CheckPanel;
const Journal = journal_mod.Journal;
const JournalEntry = journal_mod.JournalEntry;
/// One displayable finding. Borrows all string data from the
/// underlying `Observation` and (optionally) the matching journal
@ -57,7 +55,7 @@ pub const FindingRow = struct {
/// The matching journal entry (when `is_acked == true`), so
/// renderers can pull notes/acknowledged_at out without re-doing
/// the lookup. Null when no ack matches.
ack_entry: ?*const JournalEntry = null,
ack_entry: ?*const Journal.Entry = null,
};
/// Result of `build`. Owns only the `rows` slice; all string data
@ -153,7 +151,7 @@ pub fn build(
/// Linear scan is fine for realistic journal sizes (tens of entries).
/// If a portfolio ever accumulates hundreds of acks we can index by
/// (observation, target) at load time.
fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const JournalEntry {
fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const Journal.Entry {
for (journal.entries) |*e| {
if (e.ack.state != .acknowledged) continue;
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
@ -217,12 +215,12 @@ fn makeJournalWithAck(
allocator: std.mem.Allocator,
observation: []const u8,
target: []const u8,
state: journal_mod.State,
state: Journal.State,
) !Journal {
const obs_dup = try allocator.dupe(u8, observation);
const tgt_dup = try allocator.dupe(u8, target);
const empty_notes = try allocator.alloc([]const u8, 0);
const entries = try allocator.alloc(JournalEntry, 1);
const entries = try allocator.alloc(Journal.Entry, 1);
entries[0] = .{
.ack = .{
.observation = obs_dup,
@ -236,7 +234,7 @@ fn makeJournalWithAck(
}
fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal {
const entries = try allocator.alloc(JournalEntry, 0);
const entries = try allocator.alloc(Journal.Entry, 0);
return .{ .allocator = allocator, .entries = entries };
}

View file

@ -33,6 +33,7 @@ const analysis = @import("../analytics/analysis.zig");
const performance = @import("../analytics/performance.zig");
const risk = @import("../analytics/risk.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
const observations_engine = @import("../analytics/observations.zig");
const classification = @import("../models/classification.zig");
const valuation = @import("../analytics/valuation.zig");
const format = @import("../format.zig");
@ -150,9 +151,15 @@ pub const ReviewView = struct {
total_liquid: f64,
/// Anchor portfolio file path for the header line. Borrowed.
portfolio_path: []const u8,
/// Observation engine output. Null when the engine wasn't run
/// (e.g. unit tests of the renderer that fabricate views without
/// going through `buildReview`). Populated by `buildReview` after
/// computing rows + totals.
observations: ?observations_engine.CheckPanel = null,
pub fn deinit(self: *ReviewView, allocator: std.mem.Allocator) void {
allocator.free(self.rows);
if (self.observations) |*panel| panel.deinit();
}
};
@ -175,6 +182,7 @@ pub const ReviewView = struct {
/// `as_of` is the reference date for trailing-window math.
pub fn buildReview(
allocator: std.mem.Allocator,
io: std.Io,
summary: valuation.PortfolioSummary,
candle_map: *const std.StringHashMap([]const zfin.Candle),
dividend_map: ?*const std.StringHashMap([]const zfin.Dividend),
@ -233,12 +241,26 @@ pub fn buildReview(
const totals = computeTotals(rows.items, synth);
const rows_slice = try rows.toOwnedSlice(allocator);
errdefer allocator.free(rows_slice);
// Run the observations engine over the now-built rows + totals.
// Sync today; the API takes `io` so a future async dispatch path
// doesn't require a signature change.
const obs_ctx: observations_engine.CheckCtx = .{
.allocator = allocator,
.rows = rows_slice,
.totals = totals,
};
const panel = try observations_engine.runChecks(allocator, io, obs_ctx, &observations_engine.default_checks);
return .{
.rows = try rows.toOwnedSlice(allocator),
.rows = rows_slice,
.totals = totals,
.as_of = as_of,
.total_liquid = summary.total_value,
.portfolio_path = portfolio_path,
.observations = panel,
};
}
@ -1145,6 +1167,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" {
var view = try buildReview(
testing.allocator,
std.testing.io,
summary,
&candle_map,
null, // no dividend map