zfin/src/analytics/observations.zig

1179 lines
44 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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));
}
}
}