//! 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`. Sync checks /// are `.complete` immediately after `runChecks` returns; /// `is_long_running` checks start `.pending` and transition to /// `.complete` via `poll` or `awaitResult`. pub const PendingCheck = struct { check: *const Check, /// Completion flag set by the async wrapper as its final /// action. `poll` reads this to detect completion without /// blocking — `std.Io.Future` itself only offers blocking /// `await`/`cancel`, so the flag is what makes a /// non-blocking probe possible. The tiny window between /// flag-set and the future's internal result write is /// covered by the (then nearly instant) `await` in `poll`. done: std.atomic.Value(bool) = .init(false), state: union(enum) { complete: CheckResult, pending: std.Io.Future(CheckResult), }, /// Returns the resolved `CheckResult`, awaiting (blocking) if /// necessary. Idempotent. pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult { switch (self.state) { .complete => |r| return r, .pending => |*f| { const r = f.await(io); self.state = .{ .complete = r }; return r; }, } } /// Non-blocking completion probe. Returns true when the /// result is available in `.complete` (transitioning it /// there if the async task just finished). Returns false /// while the task is still running. pub fn poll(self: *PendingCheck, io: std.Io) bool { switch (self.state) { .complete => return true, .pending => |*f| { if (!self.done.load(.acquire)) return false; const r = f.await(io); self.state = .{ .complete = r }; return true; }, } } }; /// 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, io: std.Io, pending: []PendingCheck, pub fn deinit(self: *CheckPanel) void { for (self.pending) |*pc| { // Resolve any still-pending future before freeing. // `cancel` requests cancelation and blocks until the // task returns — the task may still produce a full // result (checks don't hit cancelation points today), // which we then free like any complete result. const result = switch (pc.state) { .complete => |r| r, .pending => |*f| f.cancel(self.io), }; freeResult(self.allocator, result); } self.allocator.free(self.pending); self.* = undefined; } /// True iff every check has a resolved result. Non-blocking: /// polls each pending check, transitioning newly-finished /// ones to `.complete` as a side effect. pub fn isComplete(self: *CheckPanel) bool { var all = true; for (self.pending) |*pc| { if (!pc.poll(self.io)) all = false; } return all; } }; /// 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 ──────────────────────────────────────────────────── /// Async wrapper for long-running checks. Runs the check, then /// sets the completion flag as the final action so `poll` can /// detect the result without blocking. /// /// IMPORTANT: `done` points into the panel's `pending` array, /// and `ctx`'s borrowed slices (`rows`, `totals`) must outlive /// the task. Both invariants are owned by `runChecks`'s caller /// contract: the panel and the review view that owns ctx's rows /// live until `CheckPanel.deinit`, which resolves all futures /// before anything is freed. fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool)) CheckResult { const result = check.run(ctx); done.store(true, .release); return result; } /// Run the registered checks against the given context. Returns a /// `CheckPanel` that renderers consume. /// /// Sync checks (`is_long_running == false`) complete inline; the /// panel entry is `.complete` on return. Long-running checks are /// dispatched via `io.async` and start `.pending`; callers /// resolve them with `PendingCheck.poll` (non-blocking, for /// progressive TUI rendering) or `awaitResult` (blocking, for /// CLI output). /// /// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by /// in-flight async checks. The caller must keep them alive until /// every pending check resolves — in practice both the panel and /// the rows live on the same `ReviewView` and are torn down /// together by `ReviewView.deinit` (panel first, which resolves /// stragglers via cancel). pub fn runChecks( allocator: std.mem.Allocator, io: std.Io, ctx: CheckCtx, checks: []const Check, ) !CheckPanel { var pending = try allocator.alloc(PendingCheck, checks.len); errdefer allocator.free(pending); // Two passes: initialize every slot first so the `done` // flags have stable addresses, THEN spawn the async tasks // that point at them. (Spawning during the init loop would // hand out pointers into an array we're still writing.) for (checks, 0..) |*check, i| { pending[i] = .{ .check = check, .state = .{ .complete = .skipped } }; } for (checks, 0..) |*check, i| { if (check.is_long_running) { pending[i].state = .{ .pending = io.async(runCheckTask, .{ check, ctx, &pending[i].done }), }; } else { pending[i].state = .{ .complete = check.run(ctx) }; } } return .{ .allocator = allocator, .io = io, .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.bucket) orelse 0; sector_weights.put(row.bucket, 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-bucket pairs. With n typically <50, // this should be 2500 comparisons of f64s. // // Skip pairs whose bucket contains '/'. Those are the // NPORT-P fund-decomp categories ("Equity / Corporate", // "Debt / Corporate", etc.) — meaningless for dominance // because they lump together genuinely different funds // (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate"). // The composite-fallback buckets ("US ETF", "International // Developed Fund") and user-curated buckets don't contain // '/' and survive the filter. for (ctx.rows, 0..) |a_row, i| { if (a_row.weight < min_weight) continue; if (std.mem.indexOfScalar(u8, a_row.bucket, '/') != null) 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.bucket, b_row.bucket)) 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.bucket, 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, .bucket = 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 "checkSectorDominance: bucket containing '/' is skipped (NPORT-P mush filter)" { // Two funds with hugely different Sharpes both bucketed as // "Equity / Corporate" — the upstream NPORT-P category that // lumps genuinely-different funds. Filter should suppress // this pair entirely. var rows = [_]review_view.ReviewRow{ makeRowWithVolAndSharpe("FRDM", "Equity / Corporate", 0.30, 0.20, 1.50), makeRowWithVolAndSharpe("SCHD", "Equity / Corporate", 0.30, 0.13, 0.80), makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.40), }; const ctx: CheckCtx = .{ .allocator = testing.allocator, .rows = &rows, .totals = emptyTotals(), }; const result = checkSectorDominance(ctx); defer freeResult(testing.allocator, result); // Spread is 0.70 (flag-worthy) but the '/' filter suppresses it. try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result); } test "checkSectorDominance: composite-fallback bucket survives the '/' filter" { // After deriveBucket runs, NPORT-P sectors get composited // into "International Developed Fund" / similar. Those // strings DON'T contain '/', so dominance comparisons within // composite buckets ARE meaningful and should fire. // // Weights chosen to clear the min_weight = (1/n) * 0.5 // threshold (n=3 → min 0.167; both holdings at 0.30). var rows = [_]review_view.ReviewRow{ makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50), makeRowWithVolAndSharpe("HFXI", "International Developed Fund", 0.30, 0.11, 0.60), makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.40), }; const ctx: CheckCtx = .{ .allocator = testing.allocator, .rows = &rows, .totals = emptyTotals(), }; const result = checkSectorDominance(ctx); defer freeResult(testing.allocator, result); // Spread 0.90 = flag. switch (result) { .flag => |obs| try testing.expect(obs.len >= 1), else => return error.TestUnexpectedResult, } } 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); // Await everything (drift is is_long_running and runs async); // after awaiting, the panel must report complete. for (panel.pending) |*pc| _ = pc.awaitResult(std.testing.io); 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.awaitResult(std.testing.io)); try testing.expect(tag == .pass or tag == .skipped); } } test "runChecks: async check resolves via poll without blocking forever" { // Pin the async-dispatch contract: an is_long_running check // starts pending and becomes complete via poll once its task // finishes. We can't observe the intermediate pending state // deterministically (the task may finish before we look), // but we CAN assert the poll loop terminates and yields the // right result. const slow_check = [_]Check{.{ .name = "test_async", .label = "Test async", .is_long_running = true, .run = struct { fn run(c: CheckCtx) CheckResult { _ = c; return .pass; } }.run, }}; const ctx: CheckCtx = .{ .allocator = testing.allocator, .rows = &.{}, .totals = emptyTotals(), }; var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check); defer panel.deinit(); // Poll until complete (bounded loop; the task is trivially // fast, so thousands of iterations would indicate a real // hang — fail rather than spin forever). var iterations: usize = 0; while (!panel.isComplete()) : (iterations += 1) { try testing.expect(iterations < 1_000_000); } try testing.expectEqual(CheckResult.pass, panel.pending[0].awaitResult(std.testing.io)); } test "runChecks: deinit with unresolved async check does not leak or crash" { // The panel may be torn down while a check is still pending // (user quits the TUI mid-poll). deinit must cancel/resolve // the future and free whatever result it produced. const slow_check = [_]Check{.{ .name = "test_async_abandon", .label = "Test abandon", .is_long_running = true, .run = struct { fn run(c: CheckCtx) CheckResult { // Allocate a real finding so deinit has something // to free — exercises the result-ownership path. const obs = c.allocator.alloc(Observation, 1) catch return .pass; obs[0] = .{ .severity = .warn, .kind = c.allocator.dupe(u8, "test_async_abandon") catch return .pass, .target = c.allocator.dupe(u8, "TEST") catch return .pass, .text = c.allocator.dupe(u8, "test finding") catch return .pass, }; return .{ .warn = obs }; } }.run, }}; const ctx: CheckCtx = .{ .allocator = testing.allocator, .rows = &.{}, .totals = emptyTotals(), }; var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check); // Deinit immediately — no poll, no await. testing.allocator // catches any leak of the result allocations. panel.deinit(); } 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)); } } }