From 5be11b2f09d3ff7afefbc143484b0a8c86f48d7a Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 9 Jun 2026 12:30:21 -0700 Subject: [PATCH] add observations engine and acknowledgement journal --- src/analytics/observations.zig | 969 +++++++++++++++++++++++++++++++++ src/data/journal.zig | 708 ++++++++++++++++++++++++ src/data/staleness.zig | 10 +- 3 files changed, 1686 insertions(+), 1 deletion(-) create mode 100644 src/analytics/observations.zig create mode 100644 src/data/journal.zig diff --git a/src/analytics/observations.zig b/src/analytics/observations.zig new file mode 100644 index 0000000..a223506 --- /dev/null +++ b/src/analytics/observations.zig @@ -0,0 +1,969 @@ +//! Observations engine: portfolio sanity checks for the review surface. +//! +//! Each "check" is a self-contained function that examines the live +//! review-view data (rows + totals) and returns a `CheckResult`: +//! +//! - `pass` — the check ran, no issue. +//! - `warn` — approaching a threshold; user should pay attention. +//! - `flag` — over a threshold; user should consider acting. +//! - `skipped` — the check is registered but disabled for this run +//! (drift falls into this slot until temporal observations ship). +//! - `err` — the check ran but couldn't compute (missing data, etc). +//! +//! The engine runs registered checks via `runChecks` and returns a +//! `CheckPanel` that the renderer reads to draw the status grid + the +//! findings table. Checks are async-ready via `is_long_running`: today +//! every check is sync, but the contract supports background dispatch +//! via `std.Io.async` for future slow checks (drift detection over +//! snapshot diffs, benchmark-relative thresholds, etc). +//! +//! ## Threshold scaling +//! +//! Concentration thresholds (position, sector) scale with portfolio +//! size — fixed-percentage thresholds break for portfolios outside the +//! typical 10-30 position range. Each scale-aware check uses a +//! multiplier-with-clamps formula: +//! +//! threshold = clamp(multiplier × equal_weight, floor, cap) +//! +//! For 27 positions, equal_weight = 3.7%, position warn at 4× = ~15% +//! (clamps at floor 10%). For 10 positions, equal_weight = 10%, warn +//! at 4× = 40% (clamps at cap 50%). Multipliers + clamps tuned for +//! typical real-world portfolios; revisit annually via the +//! `observation_thresholds_last_reviewed` staleness anchor below. + +const std = @import("std"); +const Date = @import("../Date.zig"); +const review_view = @import("../views/review.zig"); + +// ── Public types ────────────────────────────────────────────── + +/// Severity of a flagged finding. `pass` and `skipped` checks don't +/// produce findings; only warn / flag / err do. +pub const Severity = enum { warn, flag, err }; + +/// One finding produced by a check. Multiple findings per check are +/// allowed (e.g. position concentration emits one per overweight +/// holding). Pure data; allocator-owned. +pub const Observation = struct { + severity: Severity, + /// Stable observation kind — the `Check.name` of the check that + /// produced this finding. Used by the journal as the + /// `observation` field of an `Acknowledgment`. + kind: []const u8, + /// Per-check target string convention. `"NVDA"` for single-symbol + /// observations; `"sector:Technology"` for sector-scoped; + /// `"VTI,SCHD"` for pair-based dominance. The journal looks up + /// acks by `(observation, target)` so the convention must be + /// stable per check. + target: []const u8, + /// Human-readable text rendered in the findings table. Includes + /// thresholds and current value; e.g. "NVDA at 18.2% of liquid + /// (warn at 14.8%, flag at 22.2%)". + text: []const u8, +}; + +/// Result of running one check. Allocator semantics: `warn` and +/// `flag` slices, plus the strings inside their `Observation`s, are +/// allocated by the runner against the panel's allocator and freed +/// in `CheckPanel.deinit`. `err` strings are similarly allocated. +pub const CheckResult = union(enum) { + pass, + warn: []const Observation, + flag: []const Observation, + skipped, + err: []const u8, +}; + +/// Inputs every check sees. The `allocator` is for the check to +/// allocate its result strings against; the runner takes ownership +/// of those allocations at the end of the dispatch. +pub const CheckCtx = struct { + allocator: std.mem.Allocator, + rows: []const review_view.ReviewRow, + totals: review_view.ReviewTotals, +}; + +/// Definition of a single check. Multiple `Check` values are +/// registered in `default_checks` below. +pub const Check = struct { + /// Stable identifier; used as the `observation` field on journal + /// acks. Snake_case, no whitespace. Changing this is a breaking + /// change for any portfolio with existing acks. + name: []const u8, + /// Human-readable label for the status grid. ~21 chars or less + /// for the current 4-column layout budget. + label: []const u8, + /// Hint to the engine: should this run on a background thread? + /// All milestone-2 checks are pure-CPU and complete in + /// microseconds; this stays `false`. Drift detection (when it + /// ships) sets it `true` because snapshot diffing is I/O. + is_long_running: bool = false, + /// The check itself. Pure function over `CheckCtx`. Returns a + /// `CheckResult` whose owned allocations belong to `ctx.allocator`. + run: *const fn (ctx: CheckCtx) CheckResult, +}; + +/// Per-check execution state, lives on `CheckPanel`. Today every +/// check is sync so `state` is always `.complete` immediately after +/// `runChecks` returns. The async path is in place for future use. +pub const PendingCheck = struct { + check: *const Check, + state: union(enum) { + complete: CheckResult, + // Future: pending: std.Io.Future(CheckResult), populated + // by io.async() and resolved via poll/await. Adding a + // variant here is a non-breaking API change for current + // consumers. + }, + + /// Returns the resolved `CheckResult`, awaiting if necessary. + /// Idempotent. + pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult { + _ = io; + return switch (self.state) { + .complete => |r| r, + }; + } +}; + +/// Runtime-built panel of checks + their results. Owned by the +/// caller; `deinit` releases all allocated memory (including the +/// per-finding strings inside each `CheckResult`). +pub const CheckPanel = struct { + allocator: std.mem.Allocator, + pending: []PendingCheck, + + pub fn deinit(self: *CheckPanel) void { + for (self.pending) |pc| freeResult(self.allocator, pc.state.complete); + self.allocator.free(self.pending); + self.* = undefined; + } + + /// True iff every check has a resolved result. Today always + /// returns `true` immediately after `runChecks` (sync only). + pub fn isComplete(self: *const CheckPanel) bool { + for (self.pending) |pc| { + switch (pc.state) { + .complete => {}, + } + } + return true; + } +}; + +/// Free the strings owned by a `CheckResult`. Called from +/// `CheckPanel.deinit`; checks themselves allocate against +/// `ctx.allocator`. +fn freeResult(a: std.mem.Allocator, result: CheckResult) void { + switch (result) { + .pass, .skipped => {}, + .warn => |obs| freeObservations(a, obs), + .flag => |obs| freeObservations(a, obs), + .err => |msg| a.free(msg), + } +} + +fn freeObservations(a: std.mem.Allocator, obs: []const Observation) void { + for (obs) |o| { + a.free(o.kind); + a.free(o.target); + a.free(o.text); + } + a.free(obs); +} + +// ── Runner ──────────────────────────────────────────────────── + +/// Run the registered checks against the given context. Returns a +/// `CheckPanel` that renderers consume. Sync today; the contract +/// allows a future async dispatch path for `is_long_running` checks. +pub fn runChecks( + allocator: std.mem.Allocator, + io: std.Io, + ctx: CheckCtx, + checks: []const Check, +) !CheckPanel { + _ = io; + var pending = try allocator.alloc(PendingCheck, checks.len); + errdefer allocator.free(pending); + + for (checks, 0..) |*check, i| { + const result = check.run(ctx); + pending[i] = .{ .check = check, .state = .{ .complete = result } }; + } + + return .{ .allocator = allocator, .pending = pending }; +} + +// ── Threshold constants ─────────────────────────────────────── +// +// See module-level comment for the multiplier-with-clamps rationale. + +// Position concentration: a single holding too large relative to a +// portfolio of N positions. +const position_warn_multiplier: f64 = 4.0; +const position_warn_floor: f64 = 0.10; +const position_warn_cap: f64 = 0.50; +const position_flag_multiplier: f64 = 6.0; +const position_flag_floor: f64 = 0.15; +const position_flag_cap: f64 = 0.70; + +// Sector concentration: too much weight in a single sector relative +// to a portfolio of M represented sectors. +const sector_warn_multiplier: f64 = 2.5; +const sector_warn_floor: f64 = 0.20; +const sector_warn_cap: f64 = 0.60; +const sector_flag_multiplier: f64 = 4.0; +const sector_flag_floor: f64 = 0.30; +const sector_flag_cap: f64 = 0.75; + +// Vol outlier: ratio of holding's 3Y vol to portfolio 3Y vol. +const vol_outlier_warn_ratio: f64 = 1.8; +const vol_outlier_flag_ratio: f64 = 2.5; + +// Sector dominance: Sharpe spread within a same-sector pair, where +// both holdings have weight > min_weight_factor × equal_weight. +const dominance_warn_spread: f64 = 0.4; +const dominance_flag_spread: f64 = 0.7; +const dominance_min_weight_factor: f64 = 0.5; + +// Tiny position: relative weight floor (no absolute-dollar +// threshold; relative-only by user decision). +const tiny_warn_weight: f64 = 0.005; +const tiny_flag_weight: f64 = 0.0025; + +/// Annual sanity-check anchor for the threshold constants above. +/// Like the review-tab MaxDD anchor, these values are calibrated +/// against typical portfolios; they may need tuning over time. +/// +/// Annual recheck procedure: +/// 1. Run `zfin review` against your portfolio. +/// 2. Status grid should show ✅ on most checks for a well- +/// diversified portfolio; ⚠️ or ❌ should be rare and +/// intentional. +/// 3. If a check ALWAYS flags or NEVER flags across reasonable +/// portfolios, the multipliers / clamps need adjustment. +/// +/// Bump the date when satisfied; otherwise tune first then bump. +/// +/// Registered with the staleness checker in `src/data/staleness.zig`. +pub const observation_thresholds_last_reviewed: Date = Date.fromYmd(2026, 6, 8); + +// ── Default check registry ──────────────────────────────────── + +pub const default_checks = [_]Check{ + .{ + .name = "position_concentration", + .label = "Position concentration", + .run = checkPositionConcentration, + }, + .{ + .name = "sector_concentration", + .label = "Sector concentration", + .run = checkSectorConcentration, + }, + .{ + .name = "sector_dominance", + .label = "Sector dominance", + .run = checkSectorDominance, + }, + .{ + .name = "vol_outlier", + .label = "Vol outlier", + .run = checkVolOutlier, + }, + .{ + .name = "tiny_position", + .label = "Tiny position", + .run = checkTinyPosition, + }, + .{ + .name = "drift", + .label = "Drift since last view", + .is_long_running = true, // future: snapshot diff is I/O-bound + .run = checkDrift, + }, +}; + +// ── Check implementations ───────────────────────────────────── + +fn checkPositionConcentration(ctx: CheckCtx) CheckResult { + if (ctx.rows.len == 0) return .pass; + const n: f64 = @floatFromInt(ctx.rows.len); + const equal_weight = 1.0 / n; + const warn_thresh = std.math.clamp(position_warn_multiplier * equal_weight, position_warn_floor, position_warn_cap); + const flag_thresh = std.math.clamp(position_flag_multiplier * equal_weight, position_flag_floor, position_flag_cap); + + return collectFindingsByWeight( + ctx, + "position_concentration", + warn_thresh, + flag_thresh, + &positionFindingsBuilder, + ); +} + +fn positionFindingsBuilder( + a: std.mem.Allocator, + row: review_view.ReviewRow, + severity: Severity, + warn_thresh: f64, + flag_thresh: f64, +) !Observation { + const text = try std.fmt.allocPrint(a, "{s} at {d:.1}% of liquid (warn at {d:.1}%, flag at {d:.1}%)", .{ + row.symbol, + row.weight * 100.0, + warn_thresh * 100.0, + flag_thresh * 100.0, + }); + return .{ + .severity = severity, + .kind = try a.dupe(u8, "position_concentration"), + .target = try a.dupe(u8, row.symbol), + .text = text, + }; +} + +const FindingBuilder = *const fn ( + a: std.mem.Allocator, + row: review_view.ReviewRow, + severity: Severity, + warn: f64, + flag: f64, +) anyerror!Observation; + +fn collectFindingsByWeight( + ctx: CheckCtx, + kind: []const u8, + warn_thresh: f64, + flag_thresh: f64, + builder: FindingBuilder, +) CheckResult { + _ = kind; + var findings = std.ArrayList(Observation).empty; + errdefer { + for (findings.items) |o| { + ctx.allocator.free(o.kind); + ctx.allocator.free(o.target); + ctx.allocator.free(o.text); + } + findings.deinit(ctx.allocator); + } + var has_flag = false; + var has_warn = false; + + for (ctx.rows) |row| { + const sev: ?Severity = if (row.weight >= flag_thresh) + .flag + else if (row.weight >= warn_thresh) + .warn + else + null; + const s = sev orelse continue; + const obs = builder(ctx.allocator, row, s, warn_thresh, flag_thresh) catch return errResult(ctx.allocator, "allocation failed"); + findings.append(ctx.allocator, obs) catch return errResult(ctx.allocator, "allocation failed"); + if (s == .flag) has_flag = true else if (s == .warn) has_warn = true; + } + + if (findings.items.len == 0) return .pass; + const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "allocation failed"); + + // If any finding is a flag, the whole check is `flag`. Otherwise + // it's `warn`. The per-finding severity inside the slice tells + // the renderer what color each row gets; the wrapping variant + // tells the status-grid glyph what to show. + if (has_flag) return .{ .flag = slice }; + if (has_warn) return .{ .warn = slice }; + return .pass; +} + +fn errResult(a: std.mem.Allocator, msg: []const u8) CheckResult { + const owned = a.dupe(u8, msg) catch return .pass; // fallback: silently pass on alloc failure + return .{ .err = owned }; +} + +fn checkSectorConcentration(ctx: CheckCtx) CheckResult { + if (ctx.rows.len == 0) return .pass; + + // Aggregate weight per sector. + var sector_weights = std.StringHashMap(f64).init(ctx.allocator); + defer sector_weights.deinit(); + + for (ctx.rows) |row| { + const existing = sector_weights.get(row.sector_mid) orelse 0; + sector_weights.put(row.sector_mid, existing + row.weight) catch return errResult(ctx.allocator, "alloc failed"); + } + + const m: f64 = @floatFromInt(sector_weights.count()); + if (m == 0) return .pass; + const equal_weight = 1.0 / m; + const warn_thresh = std.math.clamp(sector_warn_multiplier * equal_weight, sector_warn_floor, sector_warn_cap); + const flag_thresh = std.math.clamp(sector_flag_multiplier * equal_weight, sector_flag_floor, sector_flag_cap); + + var findings = std.ArrayList(Observation).empty; + errdefer { + for (findings.items) |o| { + ctx.allocator.free(o.kind); + ctx.allocator.free(o.target); + ctx.allocator.free(o.text); + } + findings.deinit(ctx.allocator); + } + var has_flag = false; + var has_warn = false; + + var it = sector_weights.iterator(); + while (it.next()) |entry| { + const weight = entry.value_ptr.*; + const sev: ?Severity = if (weight >= flag_thresh) + .flag + else if (weight >= warn_thresh) + .warn + else + null; + const s = sev orelse continue; + const text = std.fmt.allocPrint(ctx.allocator, "{s} sector at {d:.1}% (warn at {d:.1}%, flag at {d:.1}%)", .{ + entry.key_ptr.*, + weight * 100.0, + warn_thresh * 100.0, + flag_thresh * 100.0, + }) catch return errResult(ctx.allocator, "alloc failed"); + const target = std.fmt.allocPrint(ctx.allocator, "sector:{s}", .{entry.key_ptr.*}) catch { + ctx.allocator.free(text); + return errResult(ctx.allocator, "alloc failed"); + }; + const kind = ctx.allocator.dupe(u8, "sector_concentration") catch { + ctx.allocator.free(text); + ctx.allocator.free(target); + return errResult(ctx.allocator, "alloc failed"); + }; + findings.append(ctx.allocator, .{ + .severity = s, + .kind = kind, + .target = target, + .text = text, + }) catch return errResult(ctx.allocator, "alloc failed"); + if (s == .flag) has_flag = true else if (s == .warn) has_warn = true; + } + + if (findings.items.len == 0) return .pass; + const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed"); + if (has_flag) return .{ .flag = slice }; + if (has_warn) return .{ .warn = slice }; + return .pass; +} + +fn checkSectorDominance(ctx: CheckCtx) CheckResult { + if (ctx.rows.len < 2) return .pass; + const n: f64 = @floatFromInt(ctx.rows.len); + const min_weight = (1.0 / n) * dominance_min_weight_factor; + + var findings = std.ArrayList(Observation).empty; + errdefer { + for (findings.items) |o| { + ctx.allocator.free(o.kind); + ctx.allocator.free(o.target); + ctx.allocator.free(o.text); + } + findings.deinit(ctx.allocator); + } + var has_flag = false; + var has_warn = false; + + // O(n²) walk over same-sector pairs. With n typically <50, + // this should be 2500 comparisons of f64s + for (ctx.rows, 0..) |a_row, i| { + if (a_row.weight < min_weight) continue; + const a_sharpe = a_row.sharpe_3y orelse continue; + for (ctx.rows[i + 1 ..]) |b_row| { + if (b_row.weight < min_weight) continue; + if (!std.mem.eql(u8, a_row.sector_mid, b_row.sector_mid)) continue; + const b_sharpe = b_row.sharpe_3y orelse continue; + const spread = @abs(a_sharpe - b_sharpe); + const sev: ?Severity = if (spread >= dominance_flag_spread) + .flag + else if (spread >= dominance_warn_spread) + .warn + else + null; + const s = sev orelse continue; + + // The "dominant" holding is the higher-Sharpe one. + const winner = if (a_sharpe > b_sharpe) a_row.symbol else b_row.symbol; + const loser = if (a_sharpe > b_sharpe) b_row.symbol else a_row.symbol; + const winner_sharpe = if (a_sharpe > b_sharpe) a_sharpe else b_sharpe; + const loser_sharpe = if (a_sharpe > b_sharpe) b_sharpe else a_sharpe; + + const text = std.fmt.allocPrint(ctx.allocator, "{s} ({d:.2} 3Y Sharpe) outperforms {s} ({d:.2}) in same sector ({s}); spread {d:.2}", .{ + winner, + winner_sharpe, + loser, + loser_sharpe, + a_row.sector_mid, + spread, + }) catch return errResult(ctx.allocator, "alloc failed"); + const target = std.fmt.allocPrint(ctx.allocator, "{s},{s}", .{ winner, loser }) catch { + ctx.allocator.free(text); + return errResult(ctx.allocator, "alloc failed"); + }; + const kind = ctx.allocator.dupe(u8, "sector_dominance") catch { + ctx.allocator.free(text); + ctx.allocator.free(target); + return errResult(ctx.allocator, "alloc failed"); + }; + findings.append(ctx.allocator, .{ + .severity = s, + .kind = kind, + .target = target, + .text = text, + }) catch return errResult(ctx.allocator, "alloc failed"); + if (s == .flag) has_flag = true else if (s == .warn) has_warn = true; + } + } + + if (findings.items.len == 0) return .pass; + const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed"); + if (has_flag) return .{ .flag = slice }; + if (has_warn) return .{ .warn = slice }; + return .pass; +} + +fn checkVolOutlier(ctx: CheckCtx) CheckResult { + const port_vol = ctx.totals.vol_3y orelse return .pass; // can't compare without portfolio vol + if (port_vol <= 0) return .pass; + + var findings = std.ArrayList(Observation).empty; + errdefer { + for (findings.items) |o| { + ctx.allocator.free(o.kind); + ctx.allocator.free(o.target); + ctx.allocator.free(o.text); + } + findings.deinit(ctx.allocator); + } + var has_flag = false; + var has_warn = false; + + for (ctx.rows) |row| { + const v = row.vol_3y orelse continue; + const ratio = v / port_vol; + const sev: ?Severity = if (ratio >= vol_outlier_flag_ratio) + .flag + else if (ratio >= vol_outlier_warn_ratio) + .warn + else + null; + const s = sev orelse continue; + const text = std.fmt.allocPrint(ctx.allocator, "{s} 3Y vol {d:.1}% is {d:.1}× portfolio vol ({d:.1}%) (warn at {d:.1}×, flag at {d:.1}×)", .{ + row.symbol, + v * 100.0, + ratio, + port_vol * 100.0, + vol_outlier_warn_ratio, + vol_outlier_flag_ratio, + }) catch return errResult(ctx.allocator, "alloc failed"); + const target = ctx.allocator.dupe(u8, row.symbol) catch { + ctx.allocator.free(text); + return errResult(ctx.allocator, "alloc failed"); + }; + const kind = ctx.allocator.dupe(u8, "vol_outlier") catch { + ctx.allocator.free(text); + ctx.allocator.free(target); + return errResult(ctx.allocator, "alloc failed"); + }; + findings.append(ctx.allocator, .{ + .severity = s, + .kind = kind, + .target = target, + .text = text, + }) catch return errResult(ctx.allocator, "alloc failed"); + if (s == .flag) has_flag = true else if (s == .warn) has_warn = true; + } + + if (findings.items.len == 0) return .pass; + const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed"); + if (has_flag) return .{ .flag = slice }; + if (has_warn) return .{ .warn = slice }; + return .pass; +} + +fn checkTinyPosition(ctx: CheckCtx) CheckResult { + if (ctx.rows.len == 0) return .pass; + + var findings = std.ArrayList(Observation).empty; + errdefer { + for (findings.items) |o| { + ctx.allocator.free(o.kind); + ctx.allocator.free(o.target); + ctx.allocator.free(o.text); + } + findings.deinit(ctx.allocator); + } + var has_flag = false; + var has_warn = false; + + for (ctx.rows) |row| { + const sev: ?Severity = if (row.weight <= tiny_flag_weight) + .flag + else if (row.weight <= tiny_warn_weight) + .warn + else + null; + const s = sev orelse continue; + const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) — consider consolidating or exiting", .{ + row.symbol, + row.weight * 100.0, + tiny_warn_weight * 100.0, + tiny_flag_weight * 100.0, + }) catch return errResult(ctx.allocator, "alloc failed"); + const target = ctx.allocator.dupe(u8, row.symbol) catch { + ctx.allocator.free(text); + return errResult(ctx.allocator, "alloc failed"); + }; + const kind = ctx.allocator.dupe(u8, "tiny_position") catch { + ctx.allocator.free(text); + ctx.allocator.free(target); + return errResult(ctx.allocator, "alloc failed"); + }; + findings.append(ctx.allocator, .{ + .severity = s, + .kind = kind, + .target = target, + .text = text, + }) catch return errResult(ctx.allocator, "alloc failed"); + if (s == .flag) has_flag = true else if (s == .warn) has_warn = true; + } + + if (findings.items.len == 0) return .pass; + const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed"); + if (has_flag) return .{ .flag = slice }; + if (has_warn) return .{ .warn = slice }; + return .pass; +} + +/// Drift since last view. Currently a placeholder — returns `skipped` +/// until temporal observations ship in a follow-up. The forward-compat +/// slot in the status grid stays visible (rendered as ➖) so users +/// know the check exists; the engine just never fires it. +fn checkDrift(ctx: CheckCtx) CheckResult { + _ = ctx; + return .skipped; +} + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +fn makeRow(symbol: []const u8, sector: []const u8, weight: f64) review_view.ReviewRow { + return .{ + .symbol = symbol, + .sector_mid = sector, + .tax_pct = null, + .weight = weight, + .return_1y = null, + .return_3y = null, + .return_5y = null, + .return_10y = null, + .vol_3y = null, + .vol_10y = null, + .sharpe_3y = null, + .sharpe_10y = null, + .maxdd_5y = null, + }; +} + +fn makeRowWithVolAndSharpe(symbol: []const u8, sector: []const u8, weight: f64, vol: f64, sharpe: f64) review_view.ReviewRow { + var r = makeRow(symbol, sector, weight); + r.vol_3y = vol; + r.sharpe_3y = sharpe; + return r; +} + +fn emptyTotals() review_view.ReviewTotals { + return .{ + .weight = 1.0, + .return_1y = null, + .return_3y = null, + .return_5y = null, + .return_10y = null, + .vol_3y = null, + .vol_10y = null, + .sharpe_3y = null, + .sharpe_10y = null, + .maxdd_5y = null, + .tax_pct = null, + .reweight_flags = .{}, + }; +} + +test "checkPositionConcentration: balanced portfolio passes" { + var rows = [_]review_view.ReviewRow{ + makeRow("A", "X", 0.10), + makeRow("B", "Y", 0.10), + makeRow("C", "Z", 0.10), + makeRow("D", "W", 0.10), + makeRow("E", "V", 0.10), + makeRow("F", "U", 0.10), + makeRow("G", "T", 0.10), + makeRow("H", "S", 0.10), + makeRow("I", "R", 0.10), + makeRow("J", "Q", 0.10), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkPositionConcentration(ctx); + defer freeResult(testing.allocator, result); + try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result); +} + +test "checkPositionConcentration: large position flags with 27 holdings" { + var rows: [27]review_view.ReviewRow = undefined; + for (0..27) |i| { + rows[i] = makeRow("X", "S", 0.03); // ~equal_weight (1/27 ≈ 0.037) + } + rows[0] = makeRow("BIG", "S", 0.30); // 30%, well over flag threshold + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkPositionConcentration(ctx); + defer freeResult(testing.allocator, result); + switch (result) { + .flag => |obs| { + try testing.expect(obs.len >= 1); + // First flagged finding should be the BIG position. + var found = false; + for (obs) |o| { + if (std.mem.eql(u8, o.target, "BIG") and o.severity == .flag) found = true; + } + try testing.expect(found); + }, + else => return error.TestUnexpectedResult, + } +} + +test "checkPositionConcentration: small portfolio uses cap" { + // 4 positions, equal_weight = 25%. Multiplier × eq = 100% (warn), 150% (flag). + // Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't. + var rows = [_]review_view.ReviewRow{ + makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag — 60% is flag) + makeRow("B", "Y", 0.20), + makeRow("C", "Z", 0.10), + makeRow("D", "W", 0.10), + }; + // Wait: 60% > flag cap 70%? No, 60 < 70. So it should warn, not flag. + // Adjust to 75% to actually flag. + rows[0].weight = 0.75; + rows[1].weight = 0.10; + rows[2].weight = 0.10; + rows[3].weight = 0.05; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkPositionConcentration(ctx); + defer freeResult(testing.allocator, result); + switch (result) { + .flag => |obs| try testing.expect(obs.len >= 1), + else => return error.TestUnexpectedResult, + } +} + +test "checkSectorConcentration: dominant sector flags" { + var rows = [_]review_view.ReviewRow{ + makeRow("A", "Tech", 0.40), + makeRow("B", "Tech", 0.35), + makeRow("C", "Bonds", 0.10), + makeRow("D", "Cash", 0.10), + makeRow("E", "Energy", 0.05), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkSectorConcentration(ctx); + defer freeResult(testing.allocator, result); + // Tech at 0.75 → flag (5 sectors → flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75). + switch (result) { + .flag => |obs| { + try testing.expect(obs.len >= 1); + var tech_found = false; + for (obs) |o| { + if (std.mem.indexOf(u8, o.text, "Tech") != null) tech_found = true; + } + try testing.expect(tech_found); + }, + else => return error.TestUnexpectedResult, + } +} + +test "checkSectorDominance: pair with large Sharpe spread flags" { + var rows = [_]review_view.ReviewRow{ + makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20), + makeRowWithVolAndSharpe("XLK", "Equity", 0.20, 0.20, 0.30), + makeRowWithVolAndSharpe("BND", "Bonds", 0.30, 0.05, 0.40), + makeRowWithVolAndSharpe("CASH", "Cash", 0.20, 0.01, 0.10), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkSectorDominance(ctx); + defer freeResult(testing.allocator, result); + switch (result) { + .flag => |obs| { + try testing.expect(obs.len >= 1); + // The dominant pair is VTI vs XLK (both Equity, spread 0.9). + var pair_found = false; + for (obs) |o| { + if (std.mem.indexOf(u8, o.target, "VTI") != null and std.mem.indexOf(u8, o.target, "XLK") != null) { + pair_found = true; + } + } + try testing.expect(pair_found); + }, + else => return error.TestUnexpectedResult, + } +} + +test "checkSectorDominance: tiny holding doesn't trigger pair (min_weight filter)" { + // VTI is meaningful at 30%; the second equity holding is tiny + // (0.5%) so should be filtered out by the min-weight check. + var rows = [_]review_view.ReviewRow{ + makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20), + makeRowWithVolAndSharpe("PINK", "Equity", 0.005, 0.40, 0.10), // tiny + makeRowWithVolAndSharpe("BND", "Bonds", 0.345, 0.05, 0.40), + makeRowWithVolAndSharpe("CASH", "Cash", 0.350, 0.01, 0.10), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkSectorDominance(ctx); + defer freeResult(testing.allocator, result); + try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result); +} + +test "checkVolOutlier: holding with 3× portfolio vol flags" { + var rows = [_]review_view.ReviewRow{ + makeRowWithVolAndSharpe("VTI", "Equity", 0.40, 0.15, 1.0), + makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5), // 3.3× of portfolio + makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.3), + }; + var totals = emptyTotals(); + totals.vol_3y = 0.15; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = totals, + }; + const result = checkVolOutlier(ctx); + defer freeResult(testing.allocator, result); + switch (result) { + .flag => |obs| { + try testing.expect(obs.len >= 1); + var wild_found = false; + for (obs) |o| if (std.mem.eql(u8, o.target, "WILD")) { + wild_found = true; + }; + try testing.expect(wild_found); + }, + else => return error.TestUnexpectedResult, + } +} + +test "checkVolOutlier: passes when totals.vol_3y is null" { + var rows = [_]review_view.ReviewRow{ + makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkVolOutlier(ctx); + defer freeResult(testing.allocator, result); + try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result); +} + +test "checkTinyPosition: positions below thresholds flag" { + var rows = [_]review_view.ReviewRow{ + makeRow("LARGE", "X", 0.30), + makeRow("SMALL", "Y", 0.003), // 0.3% — under flag threshold (0.25%) ❌ NO, 0.3% > 0.25% so warns + makeRow("TINY", "Z", 0.002), // 0.2% — under flag threshold (0.25%) ✅ flags + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + const result = checkTinyPosition(ctx); + defer freeResult(testing.allocator, result); + switch (result) { + .flag => |obs| { + try testing.expect(obs.len >= 1); + var tiny_found = false; + for (obs) |o| if (std.mem.eql(u8, o.target, "TINY")) { + tiny_found = true; + }; + try testing.expect(tiny_found); + }, + else => return error.TestUnexpectedResult, + } +} + +test "checkDrift: returns skipped (placeholder)" { + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &.{}, + .totals = emptyTotals(), + }; + const result = checkDrift(ctx); + try testing.expectEqual(@as(std.meta.Tag(CheckResult), .skipped), result); +} + +test "runChecks: produces a panel with one entry per check" { + var rows = [_]review_view.ReviewRow{ + makeRow("A", "X", 0.50), + }; + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &rows, + .totals = emptyTotals(), + }; + var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks); + defer panel.deinit(); + try testing.expectEqual(default_checks.len, panel.pending.len); + try testing.expect(panel.isComplete()); +} + +test "runChecks: empty portfolio every check passes or skips" { + const ctx: CheckCtx = .{ + .allocator = testing.allocator, + .rows = &.{}, + .totals = emptyTotals(), + }; + var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks); + defer panel.deinit(); + for (panel.pending) |pc| { + const tag = std.meta.activeTag(pc.state.complete); + try testing.expect(tag == .pass or tag == .skipped); + } +} + +test "default_checks: every check name is unique" { + for (default_checks, 0..) |a, i| { + for (default_checks[i + 1 ..]) |b| { + try testing.expect(!std.mem.eql(u8, a.name, b.name)); + } + } +} diff --git a/src/data/journal.zig b/src/data/journal.zig new file mode 100644 index 0000000..15e8d82 --- /dev/null +++ b/src/data/journal.zig @@ -0,0 +1,708 @@ +//! Journal of acknowledged observations: `acknowledgments.srf`. +//! +//! The observation engine produces findings ("Position concentration: NVDA +//! at 18.2%") on every run. The user can acknowledge a finding via the +//! review tab to record reasoning ("Holding through earnings cycle") and +//! suppress it from the active findings list. The journal is the durable +//! record of those acknowledgments. +//! +//! ## File format +//! +//! Compact-form SRF, discriminated union by `type::`. Records have a +//! positional relationship: each `type::note` record attaches to the +//! most-recently-seen `type::acknowledgment`. +//! +//! ### `type::acknowledgment` +//! +//! - `observation::` — check name, e.g. `position_concentration`. +//! - `target::` — per-check string convention. `"NVDA"` for single-symbol +//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"` +//! for pair-based observations like sector dominance. +//! - `acknowledged_at::` — date the user first acked. Immutable after +//! creation. +//! - `state::` — `active` | `acknowledged` | `resolved`. +//! - `unacknowledged_at::` — info-only breadcrumb, set when the user +//! most recently un-acked. Persists across re-acks. +//! - `resolved_at::` — info-only, set when the engine auto-resolves. +//! +//! Each ack is uniquely identified by `(observation, target)`. There is +//! never more than one entry per pair — `setState` mutates in place; we +//! don't preserve transition history (git tracks that on the file). +//! +//! ### `type::note` +//! +//! Zero or more per ack. One field: +//! +//! - `line::` — single-line content. Multi-line notes are written as N +//! consecutive note records following the ack. +//! +//! Notes are positional: a note record attaches to the most-recent +//! preceding ack record. A note record before any ack is a hard parse +//! error (`error.OrphanedNote`). The visual layout in the file +//! (ack followed by indented-feeling note records) makes the +//! relationship obvious to a human reader. +//! +//! Example: +//! +//! ``` +//! #!srfv1 +//! type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-08,state::acknowledged +//! type::note,line::Holding through earnings cycle. +//! type::note,line::Will trim by Q3 2026. +//! type::acknowledgment,observation::sector_dominance,target::VTI,SCHD,acknowledged_at::2026-06-08,state::acknowledged +//! ``` +//! +//! ## Lifecycle +//! +//! - **Read:** single-pass iterator over the file. Acks push a new +//! `JournalEntry`; 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. +//! - **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 +//! file in this codebase. + +const std = @import("std"); +const srf = @import("srf"); +const Date = @import("../Date.zig"); +const atomic = @import("../atomic.zig"); + +pub const State = enum { + active, + acknowledged, + resolved, +}; + +/// One acknowledgment record. Uniquely identified by +/// `(observation, target)`. +pub const Acknowledgment = struct { + observation: []const u8, + target: []const u8, + acknowledged_at: Date, + state: State, + unacknowledged_at: ?Date = null, + resolved_at: ?Date = null, +}; + +/// 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 NoteRecord = struct { + line: []const u8, +}; + +/// Discriminated-union over the two SRF record types in the journal. +/// SRF dispatches on the `type` tag field by convention. Internal to +/// the parser/formatter. +const JournalRecord = union(enum) { + acknowledgment: Acknowledgment, + 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 { + ack: Acknowledgment, + /// Note fragments in the order the user entered them. Each is an + /// allocator-owned slice; freed by `Journal.deinit`. + notes: []const []const u8, + + /// Concatenate the notes with newlines into a single string. + /// Allocator-owned; caller frees. + pub fn fullNote(self: JournalEntry, 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; + } + + /// 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; + } +}; + +/// 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( + allocator: std.mem.Allocator, + io: std.Io, + path: []const u8, +) !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) }; + }, + else => return err, + }; + defer allocator.free(file_data); + return try parse(allocator, file_data); +} + +/// Parse pre-read file bytes into a `Journal`. Used by `load` and by +/// tests that supply synthetic data. +/// +/// Walks records in a single pass. Each `type::acknowledgment` +/// pushes a new entry with empty notes. Each `type::note` appends +/// to the most-recent entry's notes. A note before any ack +/// returns `error.OrphanedNote`. +/// +/// **Strict**: any record that fails to deserialize (missing +/// required field, unknown enum variant, garbage bytes) propagates +/// the error out of `parse`. We don't silently skip — a malformed +/// record means user-visible data loss (acks suppress findings; +/// dropping an ack pops a finding back into the active list with +/// no explanation). Better to fail loud at load time so the user +/// can fix the file. +pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { + // Empty input ⇒ empty journal. `srf.iterator` requires a version + // banner on the first line and errors out otherwise; short-circuit. + if (data.len == 0) { + return .{ + .allocator = allocator, + .entries = try allocator.alloc(JournalEntry, 0), + }; + } + + var entries = std.ArrayList(JournalEntry).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 + // 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 { + for (notes_per_entry.items) |*notes| { + for (notes.items) |line| allocator.free(line); + notes.deinit(allocator); + } + notes_per_entry.deinit(allocator); + } + + var reader = std.Io.Reader.fixed(data); + var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData; + defer it.deinit(); + + while (try it.next()) |fields| { + const rec = try fields.to(JournalRecord, .{}); + switch (rec) { + .acknowledgment => |a| { + try entries.append(allocator, .{ + .ack = .{ + .observation = try allocator.dupe(u8, a.observation), + .target = try allocator.dupe(u8, a.target), + .acknowledged_at = a.acknowledged_at, + .state = a.state, + .unacknowledged_at = a.unacknowledged_at, + .resolved_at = a.resolved_at, + }, + .notes = &.{}, // placeholder; replaced below + }); + try notes_per_entry.append(allocator, std.ArrayList([]const u8).empty); + }, + .note => |n| { + if (entries.items.len == 0) return error.OrphanedNote; + const last_notes = ¬es_per_entry.items[notes_per_entry.items.len - 1]; + try last_notes.append(allocator, try allocator.dupe(u8, n.line)); + }, + } + } + + // Move notes lists into their entries' `notes` fields as owned + // slices. After this each entry owns its own notes; the + // outer `notes_per_entry` list is empty. + for (entries.items, 0..) |*entry, i| { + entry.notes = try notes_per_entry.items[i].toOwnedSlice(allocator); + } + notes_per_entry.deinit(allocator); + + return .{ + .allocator = allocator, + .entries = try entries.toOwnedSlice(allocator), + }; +} + +/// Free a partially-built entries list (for parse errdefer). +fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntry)) void { + for (entries.items) |entry| { + allocator.free(entry.ack.observation); + allocator.free(entry.ack.target); + for (entry.notes) |line| allocator.free(line); + allocator.free(entry.notes); + } + entries.deinit(allocator); +} + +/// Append a new acknowledgment with notes, then atomic-rewrite the +/// file. The journal's in-memory state is updated to reflect the new +/// entry; caller owns the journal as before. +/// +/// `note_fragments` is the list of single-line strings the user +/// 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, + io: std.Io, + path: []const u8, + new_ack: Acknowledgment, + note_fragments: []const []const u8, +) !void { + const a = journal.allocator; + + // Build the new entry's owned strings first. errdefer cleanup + // is fiddly because we need to roll back partial allocations + // on any failure; doing it in stages keeps the pattern clear. + const owned_obs = try a.dupe(u8, new_ack.observation); + errdefer a.free(owned_obs); + const owned_target = try a.dupe(u8, new_ack.target); + errdefer a.free(owned_target); + + var owned_notes = try a.alloc([]const u8, note_fragments.len); + errdefer a.free(owned_notes); + var note_idx: usize = 0; + errdefer for (owned_notes[0..note_idx]) |line| a.free(line); + for (note_fragments, 0..) |frag, i| { + owned_notes[i] = try a.dupe(u8, frag); + note_idx = i + 1; + } + + // Grow the entries slice by one and append. + var new_entries = try a.alloc(JournalEntry, journal.entries.len + 1); + errdefer a.free(new_entries); + @memcpy(new_entries[0..journal.entries.len], journal.entries); + new_entries[journal.entries.len] = .{ + .ack = .{ + .observation = owned_obs, + .target = owned_target, + .acknowledged_at = new_ack.acknowledged_at, + .state = new_ack.state, + .unacknowledged_at = new_ack.unacknowledged_at, + .resolved_at = new_ack.resolved_at, + }, + .notes = owned_notes, + }; + + // 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; + + try writeFile(journal, io, path); +} + +/// Update the state of an existing acknowledgment, set the relevant +/// breadcrumb timestamp, and atomic-rewrite the file. The state +/// transition machine: +/// +/// - `active → acknowledged` — clears `unacknowledged_at`. +/// - `acknowledged → active` — sets `unacknowledged_at = today`. +/// - `* → resolved` — sets `resolved_at = today`. +/// - `resolved → active` — clears `resolved_at`. +/// +/// Returns `error.AckNotFound` if no entry matches `(observation, +/// target)`. +pub fn setState( + journal: *Journal, + io: std.Io, + path: []const u8, + observation: []const u8, + target: []const u8, + new_state: State, + today: Date, +) !void { + var found: ?*JournalEntry = null; + for (journal.entries) |*e| { + if (!std.mem.eql(u8, e.ack.observation, observation)) continue; + if (!std.mem.eql(u8, e.ack.target, target)) continue; + found = e; + break; + } + const entry = found orelse return error.AckNotFound; + + switch (new_state) { + .acknowledged => { + entry.ack.unacknowledged_at = null; + }, + .active => { + if (entry.ack.state == .acknowledged) entry.ack.unacknowledged_at = today; + if (entry.ack.state == .resolved) entry.ack.resolved_at = null; + }, + .resolved => { + entry.ack.resolved_at = today; + }, + } + entry.ack.state = new_state; + + try writeFile(journal, io, path); +} + +/// Atomic file write: serialize all records, then hand to +/// `atomic.writeFileAtomic` which writes to `.tmp`, fsyncs, +/// and renames. Crash-safe. +fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void { + const a = journal.allocator; + + // Build the file contents in memory. For a journal of typical + // size (dozens to low hundreds of records) this is trivially + // small; if it grows large we revisit streaming. + var buf: std.Io.Writer.Allocating = .init(a); + defer buf.deinit(); + + // Flatten into a single records slice so `srf.fmt` writes the + // `#!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 records = try a.alloc(JournalRecord, total_records); + defer a.free(records); + var ri: usize = 0; + for (journal.entries) |e| { + records[ri] = .{ .acknowledgment = e.ack }; + ri += 1; + for (e.notes) |line| { + records[ri] = .{ .note = .{ .line = line } }; + ri += 1; + } + } + + try buf.writer.print("{f}", .{srf.fmt(JournalRecord, records, .{})}); + + try atomic.writeFileAtomic(io, a, path, buf.writer.buffered()); +} + +// ── Tests ──────────────────────────────────────────────────── + +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); +} + +test "parse: single ack with two notes round-trips" { + const data = + \\#!srfv1 + \\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged + \\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]; + try testing.expectEqualStrings("position_concentration", entry.ack.observation); + try testing.expectEqualStrings("NVDA", entry.ack.target); + try testing.expectEqual(State.acknowledged, entry.ack.state); + try testing.expectEqual(@as(usize, 2), entry.notes.len); + try testing.expectEqualStrings("Holding through earnings cycle.", entry.notes[0]); + try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]); +} + +test "parse: notes attach to the most-recent preceding ack" { + const data = + \\#!srfv1 + \\type::acknowledgment,observation::p,target::A,acknowledged_at::2026-06-12,state::active + \\type::note,line::for A + \\type::acknowledgment,observation::p,target::B,acknowledged_at::2026-06-13,state::active + \\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]); +} + +test "parse: orphan note before any ack returns error.OrphanedNote" { + const data = + \\#!srfv1 + \\type::note,line::orphaned + \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active + ; + try testing.expectError(error.OrphanedNote, parse(testing.allocator, data)); +} + +test "findByTarget: finds matching, returns null for non-match" { + const data = + \\#!srfv1 + \\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(); + + const found = j.findByTarget("position_concentration", "NVDA").?; + try testing.expectEqualStrings("NVDA", found.ack.target); + + const not_found = j.findByTarget("position_concentration", "AAPL"); + try testing.expect(not_found == null); +} + +test "JournalEntry.fullNote: joins fragments with newlines" { + const data = + \\#!srfv1 + \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active + \\type::note,line::first + \\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); + defer testing.allocator.free(full); + try testing.expectEqualStrings("first\nsecond\nthird", full); +} + +test "JournalEntry.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); + defer testing.allocator.free(full); + try testing.expectEqualStrings("", full); +} + +test "parse: optional unacknowledged_at and resolved_at fields work when omitted" { + const data = + \\#!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); +} + +test "parse: optional unacknowledged_at and resolved_at fields work when set" { + const data = + \\#!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); +} + +test "parse: malformed record returns parse error" { + // A record with no `type::` discriminator should fail SRF + // deserialization (ActiveTagNotFirstField), and we propagate + // the error rather than silently skipping. The exact error + // variant is SRF's choice; we just assert that an error is + // returned. + const data = + \\#!srfv1 + \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged + \\garbage_field::nope + \\type::acknowledgment,observation::q,target::U,acknowledged_at::2026-06-13,state::active + ; + try testing.expectError(error.ActiveTagNotFirstField, parse(testing.allocator, data)); +} + +// ── I/O tests (load + append + setState round-trips) ────────── + +test "load: missing file returns empty journal" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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); +} + +test "append + load round-trip: ack with two notes" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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); + + const fragments = [_][]const u8{ "first thought", "follow-up rationale" }; + try append(&j, 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]); +} + +test "append: two acks land in append-order on reload" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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 append(&j, io, path, .{ + .observation = "k", + .target = "B", + .acknowledged_at = Date.fromYmd(2026, 6, 8), + .state = .acknowledged, + }, &.{}); + try append(&j, 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); +} + +test "setState: acknowledged → active sets unacknowledged_at" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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 append(&j, 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)); + + 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))); +} + +test "setState: missing target returns AckNotFound" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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.expectError( + error.AckNotFound, + setState(&j, io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)), + ); +} + +test "setState: → resolved sets resolved_at" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + 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 append(&j, 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)); + + 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))); +} diff --git a/src/data/staleness.zig b/src/data/staleness.zig index 085f6b3..8663a5e 100644 --- a/src/data/staleness.zig +++ b/src/data/staleness.zig @@ -37,6 +37,7 @@ const Date = @import("../Date.zig"); const risk = @import("../analytics/risk.zig"); const shiller = @import("shiller.zig"); const review = @import("../views/review.zig"); +const observations = @import("../analytics/observations.zig"); /// A hand-maintained data source that nags once a year if it hasn't /// been refreshed by its annual `(due_month, due_day)`. @@ -77,6 +78,13 @@ pub const entries = [_]StaleEntry{ .due_day = 1, .source_file = "src/views/review.zig", }, + .{ + .name = "Observation engine thresholds", + .last_updated = observations.observation_thresholds_last_reviewed, + .due_month = 6, + .due_day = 1, + .source_file = "src/analytics/observations.zig", + }, }; /// Write a warning line for each entry in `entries` that is overdue @@ -292,7 +300,7 @@ test "silent when today is one day before due" { test "real registry compiles and is non-empty" { // Guard that the registry stays wired up; doesn't assert any // particular nag behavior (real dates drift over time). - try std.testing.expect(entries.len >= 3); + try std.testing.expect(entries.len >= 4); for (entries) |e| { try std.testing.expect(e.name.len > 0); try std.testing.expect(e.source_file.len > 0);