1179 lines
44 KiB
Zig
1179 lines
44 KiB
Zig
//! 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));
|
||
}
|
||
}
|
||
}
|