zfin/src/analytics/timeline.zig

2596 lines
101 KiB
Zig

//! Portfolio timeline analytics — pure compute over snapshot history.
//!
//! This module takes pre-loaded portfolio snapshots (see `src/history.zig`
//! for the IO layer that produces them) and reduces them to time-series
//! data for display or export. Nothing here touches the filesystem, the
//! network, or a writer — that's by design, so the logic can be tested
//! exhaustively with fixture data.
//!
//! Typical flow:
//! snapshots : []const Snapshot <- history.loadHistoryDir(...)
//! │
//! ▼
//! buildSeries(snapshots) -> TimelineSeries (sorted by date)
//! │
//! ▼
//! filterByDate(series, since, until) (optional)
//! │
//! ▼
//! extractMetric(series, .net_worth) -> []MetricPoint for rendering
//!
//! For rollup generation, `buildRollupRecords` emits a flat slice suitable
//! for `srf.fmtFrom` without any of the per-lot detail — the rollup is a
//! summary cache, not a replacement for the per-day snapshot files.
const std = @import("std");
const Date = @import("../Date.zig");
const snapshot = @import("../models/snapshot.zig");
const valuation = @import("valuation.zig");
const HistoricalPeriod = valuation.HistoricalPeriod;
// ── Public types ─────────────────────────────────────────────
/// A single point on the portfolio timeline. Totals are always present
/// (they're the three `kind::total` rows every snapshot emits); the
/// per-account / per-tax-type maps are only populated when the source
/// snapshot included analysis breakdowns.
///
/// Values are dollar amounts. Weights aren't stored — callers can
/// compute them cheaply from the totals when rendering.
pub const TimelinePoint = struct {
as_of_date: Date,
net_worth: f64,
liquid: f64,
illiquid: f64,
/// Per-account totals. Empty when the source snapshot had no
/// analysis breakdown (e.g. backfilled-from-.txt snapshots).
/// Keys are account names; values are dollar totals.
accounts: []const NamedValue,
/// Per-tax-type totals. Same caveat as `accounts`.
tax_types: []const NamedValue,
/// Where this point came from. `.snapshot` (default) means a
/// native `*-portfolio.srf` file; `.imported` means a
/// `imported_values.srf` row that was merged in. Imported
/// points carry only `liquid` (illiquid/net_worth are zero,
/// accounts/tax_types are empty).
source: Source = .snapshot,
pub const Source = enum { snapshot, imported };
/// Deinit releases only the owned slices (`accounts`, `tax_types`).
/// The strings inside those slices are borrowed from the source
/// Snapshot's arena and must not be freed here.
pub fn deinit(self: TimelinePoint, allocator: std.mem.Allocator) void {
allocator.free(self.accounts);
allocator.free(self.tax_types);
}
};
pub const NamedValue = struct {
name: []const u8,
value: f64,
};
/// An ordered series of TimelinePoint sorted ascending by as_of_date.
/// Duplicates by date shouldn't happen (snapshot filenames enforce one
/// per day) but if they do the caller's last-write-wins.
pub const TimelineSeries = struct {
points: []TimelinePoint,
allocator: std.mem.Allocator,
pub fn deinit(self: TimelineSeries) void {
for (self.points) |p| p.deinit(self.allocator);
self.allocator.free(self.points);
}
};
/// Which metric to extract for single-series display.
pub const Metric = enum {
net_worth,
liquid,
illiquid,
pub fn label(self: Metric) []const u8 {
return switch (self) {
.net_worth => "Net Worth",
.liquid => "Liquid",
.illiquid => "Illiquid",
};
}
};
/// One row of the extracted single-metric series. Used as input to
/// renderers and for mathematical operations (diffs, deltas, sparklines).
pub const MetricPoint = struct {
date: Date,
value: f64,
};
// ── Core construction ────────────────────────────────────────
/// Build a TimelineSeries from a slice of snapshots.
///
/// Input snapshots are NOT required to be sorted; this function sorts
/// by `as_of_date` ascending. Each returned point's `accounts` and
/// `tax_types` slices borrow strings from the source Snapshot's arena
/// but own their own outer slice (freed by TimelinePoint.deinit).
pub fn buildSeries(
allocator: std.mem.Allocator,
snapshots: []const snapshot.Snapshot,
) !TimelineSeries {
var points = try allocator.alloc(TimelinePoint, snapshots.len);
errdefer {
// On error, free any already-populated points' nested slices.
allocator.free(points);
}
var populated: usize = 0;
errdefer {
for (points[0..populated]) |p| p.deinit(allocator);
}
for (snapshots, 0..) |snap, idx| {
points[idx] = try snapshotToPoint(allocator, snap);
populated = idx + 1;
}
std.mem.sort(TimelinePoint, points, {}, lessByDate);
return .{ .points = points, .allocator = allocator };
}
fn lessByDate(_: void, a: TimelinePoint, b: TimelinePoint) bool {
return a.as_of_date.lessThan(b.as_of_date);
}
/// Derive a TimelinePoint from a single Snapshot. Pure — no IO.
///
/// Exposed for testability. `buildSeries` is the usual entry point.
pub fn snapshotToPoint(
allocator: std.mem.Allocator,
snap: snapshot.Snapshot,
) !TimelinePoint {
var nw: f64 = 0;
var liq: f64 = 0;
var ill: f64 = 0;
for (snap.totals) |t| {
if (std.mem.eql(u8, t.scope, "net_worth")) nw = t.value;
if (std.mem.eql(u8, t.scope, "liquid")) liq = t.value;
if (std.mem.eql(u8, t.scope, "illiquid")) ill = t.value;
}
const accts = try allocator.alloc(NamedValue, snap.accounts.len);
errdefer allocator.free(accts);
for (snap.accounts, 0..) |a, i| {
accts[i] = .{ .name = a.name, .value = a.value };
}
const tts = try allocator.alloc(NamedValue, snap.tax_types.len);
errdefer allocator.free(tts);
for (snap.tax_types, 0..) |t, i| {
tts[i] = .{ .name = t.label, .value = t.value };
}
return .{
.as_of_date = snap.meta.as_of_date,
.net_worth = nw,
.liquid = liq,
.illiquid = ill,
.accounts = accts,
.tax_types = tts,
.source = .snapshot,
};
}
// ── Merged series construction ───────────────────────────────
/// Build a TimelineSeries by merging native portfolio snapshots
/// with imported_values.srf rows. On overlapping dates, snapshots
/// take precedence (higher fidelity — they carry illiquid and
/// breakdowns).
///
/// Imported-only points produce TimelinePoint records with
/// `liquid` set, `illiquid = 0`, `net_worth = liquid`,
/// `accounts = &.{}`, `tax_types = &.{}`, and `source = .imported`.
/// Callers that need to skip imported-only points (e.g. for
/// illiquid/net_worth rendering) inspect `source`.
///
/// Returned series is sorted ascending by date.
pub fn buildMergedSeries(
allocator: std.mem.Allocator,
snapshots: []const snapshot.Snapshot,
imported: []const ImportedHistoryPoint,
) !TimelineSeries {
// First, collect the snapshot dates so we can skip imports
// that overlap.
var snapshot_days: std.AutoHashMap(i32, void) = .init(allocator);
defer snapshot_days.deinit();
for (snapshots) |snap| {
try snapshot_days.put(snap.meta.as_of_date.days, {});
}
var points: std.ArrayList(TimelinePoint) = .empty;
errdefer {
for (points.items) |p| p.deinit(allocator);
points.deinit(allocator);
}
// Snapshots: full-fidelity points.
for (snapshots) |snap| {
const tp = try snapshotToPoint(allocator, snap);
try points.append(allocator, tp);
}
// Imports: only those whose date isn't already covered.
for (imported) |ip| {
if (snapshot_days.contains(ip.date.days)) continue;
try points.append(allocator, .{
.as_of_date = ip.date,
.net_worth = ip.liquid,
.liquid = ip.liquid,
.illiquid = 0,
.accounts = &.{},
.tax_types = &.{},
.source = .imported,
});
}
const slice = try points.toOwnedSlice(allocator);
std.mem.sort(TimelinePoint, slice, {}, lessByDate);
return .{ .points = slice, .allocator = allocator };
}
/// Minimal cross-module type for `buildMergedSeries`. Mirrors the
/// public shape of `data/imported_values.zig:HistoryPoint` without
/// pulling that module into the analytics layer (analytics is
/// kept import-light to stay testable in isolation).
pub const ImportedHistoryPoint = struct {
date: Date,
liquid: f64,
};
// ── Filters ──────────────────────────────────────────────────
/// Return the subset of `points` whose `as_of_date` falls within the
/// inclusive `[since, until]` range. Either bound may be null to leave
/// that end open. The resulting slice is newly allocated; caller owns.
///
/// This does NOT free the input points — the caller remains responsible
/// for the original TimelineSeries.
pub fn filterByDate(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
since: ?Date,
until: ?Date,
) ![]TimelinePoint {
var kept: std.ArrayList(TimelinePoint) = .empty;
errdefer kept.deinit(allocator);
for (points) |p| {
if (since) |s| if (p.as_of_date.lessThan(s)) continue;
if (until) |u| if (u.lessThan(p.as_of_date)) continue;
try kept.append(allocator, p);
}
return kept.toOwnedSlice(allocator);
}
// ── Metric extraction ────────────────────────────────────────
/// Extract a single top-level metric into a flat `[]MetricPoint` ready
/// for rendering or statistical analysis. Result is caller-owned.
pub fn extractMetric(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
metric: Metric,
) ![]MetricPoint {
var out = try allocator.alloc(MetricPoint, points.len);
for (points, 0..) |p, i| {
out[i] = .{
.date = p.as_of_date,
.value = switch (metric) {
.net_worth => p.net_worth,
.liquid => p.liquid,
.illiquid => p.illiquid,
},
};
}
return out;
}
/// Which collection of per-row named values on `TimelinePoint` to project.
pub const NamedSeriesSource = enum { accounts, tax_types };
/// Extract a single-metric series for a named row (an account or a
/// tax-type label) from the timeline. Dates without the named row emit
/// `value = 0` rather than being skipped, so the returned slice has
/// `points.len` entries — suitable for stacked displays that need a row
/// per named entity per date.
///
/// Caller owns the returned slice.
pub fn extractNamedSeries(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
source: NamedSeriesSource,
name: []const u8,
) ![]MetricPoint {
var out = try allocator.alloc(MetricPoint, points.len);
for (points, 0..) |p, i| {
const rows = switch (source) {
.accounts => p.accounts,
.tax_types => p.tax_types,
};
var v: f64 = 0;
for (rows) |r| {
if (std.mem.eql(u8, r.name, name)) {
v = r.value;
break;
}
}
out[i] = .{ .date = p.as_of_date, .value = v };
}
return out;
}
// ── Deltas / statistics ──────────────────────────────────────
/// Summary statistics over a single-metric series.
pub const MetricStats = struct {
first: f64,
last: f64,
min: f64,
max: f64,
/// `last - first`. Dollars, not percent.
delta_abs: f64,
/// `(last - first) / first` — or null when `first == 0` (division
/// by zero); callers should render "n/a" or similar.
delta_pct: ?f64,
};
/// Compute min/max/first/last/delta over a MetricPoint slice. Returns
/// null on empty input — every field would be meaningless otherwise.
pub fn computeStats(points: []const MetricPoint) ?MetricStats {
if (points.len == 0) return null;
var min_v = points[0].value;
var max_v = points[0].value;
for (points[1..]) |p| {
if (p.value < min_v) min_v = p.value;
if (p.value > max_v) max_v = p.value;
}
const first = points[0].value;
const last = points[points.len - 1].value;
const delta = last - first;
return .{
.first = first,
.last = last,
.min = min_v,
.max = max_v,
.delta_abs = delta,
.delta_pct = if (first == 0) null else delta / first,
};
}
// ── Rollup emission ──────────────────────────────────────────
/// A single row in `history/rollup.srf`. Deliberately slim: one record
/// per date, carrying the three totals only. Per-account and per-
/// tax-type detail stays in the per-day files.
///
/// The `kind` discriminator pattern is consistent with the snapshot
/// format. Not strictly required here (the file has only one record
/// type) but future-proof if we ever add other rollup shapes.
pub const RollupRow = struct {
kind: []const u8,
as_of_date: Date,
net_worth: f64,
liquid: f64,
illiquid: f64,
};
/// Produce a rollup-row slice from a TimelineSeries. Pure function —
/// caller owns the result, ready to hand to `srf.fmtFrom`.
pub fn buildRollupRecords(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
) ![]RollupRow {
var out = try allocator.alloc(RollupRow, points.len);
for (points, 0..) |p, i| {
out[i] = .{
.kind = "rollup",
.as_of_date = p.as_of_date,
.net_worth = p.net_worth,
.liquid = p.liquid,
.illiquid = p.illiquid,
};
}
return out;
}
// ── Snap-backward for snapshot points ────────────────────────
fn pointDateOf(p: TimelinePoint) Date {
return p.as_of_date;
}
/// Return the latest point on or before `target`. Null if `points` is
/// empty or every entry sits strictly after `target`.
///
/// Delegates to the shared `valuation.indexAtOrBefore` kernel — same
/// snap-backward behavior used by candle pricing, so holiday/weekend
/// semantics are identical across the app. No slack cap: snapshot
/// history is dense enough by construction (one entry per trading day)
/// that caps would only hide real gaps.
pub fn pointAtOrBefore(points: []const TimelinePoint, target: Date) ?*const TimelinePoint {
const idx = valuation.indexAtOrBefore(TimelinePoint, points, target, pointDateOf) orelse return null;
return &points[idx];
}
// ── Rolling-windows block ────────────────────────────────────
/// One row in the rolling-windows block. `anchor_date` / `start_value` /
/// `delta_*` are null when there isn't enough history to honor the
/// window (e.g. asking for 10-year on a 2-week-old portfolio).
///
/// `end_value` is always populated — it's the latest point in the
/// series, which must exist for the block to render at all.
pub const WindowStat = struct {
/// The period this row represents. Null for the synthetic "All-time"
/// row (anchored to the first snapshot rather than to `today - N`).
period: ?HistoricalPeriod,
/// Human-facing label ("1 day", "YTD", "All-time").
label: []const u8,
/// Short label used when horizontal space is tight ("1D", "YTD").
short_label: []const u8,
/// The snapshot date we anchored to. Null when no snapshot exists at
/// or before the target date — i.e. not enough history.
anchor_date: ?Date,
/// The anchor snapshot's metric value. Null when anchor is missing.
start_value: ?f64,
/// Always populated — the latest snapshot's metric value.
end_value: f64,
/// `end_value - start_value`. Null when start is missing.
delta_abs: ?f64,
/// `(end_value - start_value) / start_value`. Null when start is
/// missing OR when start is exactly zero (division by zero).
delta_pct: ?f64,
/// CAGR — annualized growth rate over the window:
/// `(end_value / start_value)^(1/years) - 1`.
/// Null when `start_value` is missing or zero, when
/// `years <= 0` (degenerate window), or when end/start ratio
/// is non-positive (which would require equity to go negative).
/// Years are computed as `(today.days - anchor.days) / 365.25`,
/// the standard CAGR convention.
annualized_pct: ?f64,
};
/// Rolling-windows block for a single metric. Owns the `rows` slice.
/// Order: 8 relative-to-today periods (from HistoricalPeriod.timeline_windows),
/// then a final synthetic "All-time" row anchored to the first snapshot.
pub const WindowSet = struct {
rows: []WindowStat,
allocator: std.mem.Allocator,
pub fn deinit(self: WindowSet) void {
self.allocator.free(self.rows);
}
};
fn extractValue(p: TimelinePoint, metric: Metric) f64 {
return switch (metric) {
.net_worth => p.net_worth,
.liquid => p.liquid,
.illiquid => p.illiquid,
};
}
/// Build the rolling-windows block for one metric. `today` is the
/// reference "now" — almost always the last snapshot's as_of_date, but
/// taken as a parameter so tests can pin deterministic scenarios.
///
/// Returns an empty set when `points` is empty.
pub fn computeWindowSet(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
metric: Metric,
as_of: Date,
) !WindowSet {
if (points.len == 0) {
return .{ .rows = &.{}, .allocator = allocator };
}
const windows = HistoricalPeriod.timeline_windows;
var rows = try allocator.alloc(WindowStat, windows.len + 1);
errdefer allocator.free(rows);
const end_point = points[points.len - 1];
const end_value = extractValue(end_point, metric);
for (windows, 0..) |period, i| {
const target = period.targetDate(as_of);
const anchor_opt = pointAtOrBefore(points, target);
rows[i] = if (anchor_opt) |a| blk_outer: {
const sv = extractValue(a.*, metric);
const dpct: ?f64 = if (sv == 0) null else (end_value - sv) / sv;
const apct: ?f64 = annualizedFromPct(dpct, a.as_of_date, as_of);
break :blk_outer .{
.period = period,
.label = period.longLabel(),
.short_label = period.label(),
.anchor_date = a.as_of_date,
.start_value = sv,
.end_value = end_value,
.delta_abs = end_value - sv,
.delta_pct = dpct,
.annualized_pct = apct,
};
} else .{
.period = period,
.label = period.longLabel(),
.short_label = period.label(),
.anchor_date = null,
.start_value = null,
.end_value = end_value,
.delta_abs = null,
.delta_pct = null,
.annualized_pct = null,
};
}
// All-time = vs. first snapshot in series. Not a HistoricalPeriod
// member because it isn't relative to `today` (see valuation.zig
// doc block).
const first = points[0];
const first_value = extractValue(first, metric);
const all_pct: ?f64 = if (first_value == 0) null else (end_value - first_value) / first_value;
rows[windows.len] = .{
.period = null,
.label = "All-time",
.short_label = "All",
.anchor_date = first.as_of_date,
.start_value = first_value,
.end_value = end_value,
.delta_abs = end_value - first_value,
.delta_pct = all_pct,
.annualized_pct = annualizedFromPct(all_pct, first.as_of_date, as_of),
};
return .{ .rows = rows, .allocator = allocator };
}
/// Compute the annualized return (CAGR) from a cumulative pct
/// over a window. Years are derived from raw day count divided
/// by 365.25 (standard CAGR convention).
///
/// `as_of` is the reference end-date for the window — typically
/// the chart's "now" but the parameter accepts any caller-chosen
/// date (per AGENTS.md, `as_of` not `today` for arbitrary
/// caller-supplied reference dates).
///
/// Returns null when:
/// - `delta_pct` is null (no anchor),
/// - `years <= 0` (degenerate / future-dated window),
/// - `1 + delta_pct <= 0` (would require equity to go negative
/// to losses exceeding 100% — impossible from positive start
/// equity, but defensive against bad input).
fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
const dpct = delta_pct orelse return null;
const day_diff: f64 = @floatFromInt(as_of.days - anchor.days);
if (day_diff <= 0) return null;
const years = day_diff / 365.25;
const ratio = 1.0 + dpct;
if (ratio <= 0) return null;
return std.math.pow(f64, ratio, 1.0 / years) - 1.0;
}
// ── Per-row day-over-day deltas ──────────────────────────────
/// One row in the "Recent snapshots" table after per-row deltas have
/// been computed. The delta is *relative to the previous row in the
/// same resolution* — i.e. when the table is aggregated to weekly,
/// `d_*` fields hold week-over-week change.
///
/// First row has all `d_*` fields null (no prior row to compare against).
pub const RowDelta = struct {
date: Date,
liquid: f64,
illiquid: f64,
net_worth: f64,
d_liquid: ?f64,
d_illiquid: ?f64,
d_net_worth: ?f64,
};
/// Compute per-row deltas against the previous row. Returns a
/// newly-allocated slice the caller owns. Empty input -> empty output.
pub fn computeRowDeltas(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
) ![]RowDelta {
var out = try allocator.alloc(RowDelta, points.len);
for (points, 0..) |p, i| {
out[i] = .{
.date = p.as_of_date,
.liquid = p.liquid,
.illiquid = p.illiquid,
.net_worth = p.net_worth,
.d_liquid = if (i == 0) null else p.liquid - points[i - 1].liquid,
.d_illiquid = if (i == 0) null else p.illiquid - points[i - 1].illiquid,
.d_net_worth = if (i == 0) null else p.net_worth - points[i - 1].net_worth,
};
}
return out;
}
// ── Resolution (daily / weekly / monthly) ────────────────────
pub const Resolution = enum {
daily,
weekly,
monthly,
/// Multi-tier cascade — daily, weekly, monthly, quarterly,
/// yearly. Produced by `aggregateCascading`, not by the
/// `aggregatePoints` flat-aggregation function.
cascading,
pub fn label(self: Resolution) []const u8 {
return switch (self) {
.daily => "daily",
.weekly => "weekly",
.monthly => "monthly",
.cascading => "cascading",
};
}
};
/// Pick a default resolution based on series span.
/// span ≤ 90d → daily
/// span ≤ 730d → weekly
/// else → monthly
///
/// Empty / single-point series always return `daily` (there's nothing
/// to aggregate).
pub fn selectResolution(points: []const TimelinePoint) Resolution {
if (points.len < 2) return .daily;
const first = points[0].as_of_date;
const last = points[points.len - 1].as_of_date;
const span_days = last.days - first.days;
if (span_days <= 90) return .daily;
if (span_days <= 730) return .weekly;
return .monthly;
}
/// Aggregate `points` to the requested resolution. Returns a
/// newly-allocated slice the caller owns.
///
/// `daily` → returns a copy of the input.
/// `weekly` → rolling 7-day buckets walking *backward from latest*, one
/// representative point per bucket (the latest in the bucket,
/// not the oldest — matches brokerage weekly-bar convention).
/// The returned slice is sorted ascending by date.
/// `monthly` → groups by calendar (year, month); picks the latest snapshot
/// in each month. Sorted ascending by date.
///
/// Empty input returns an empty owned slice.
pub fn aggregatePoints(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
resolution: Resolution,
) ![]TimelinePoint {
if (points.len == 0) return allocator.alloc(TimelinePoint, 0);
switch (resolution) {
.daily => {
const out = try allocator.alloc(TimelinePoint, points.len);
@memcpy(out, points);
return out;
},
.weekly => return aggregateWeeklyRolling(allocator, points),
.monthly => return aggregateMonthly(allocator, points),
// Cascading is not a flat resolution; callers should use
// `aggregateCascading` directly. Emit an empty slice to
// keep this entry-point's invariants simple.
.cascading => return allocator.alloc(TimelinePoint, 0),
}
}
/// Walk backward in 7-day strides from the latest point. The latest
/// point always seeds bucket 0; subsequent buckets cover
/// `(latest - 7i - 6) … (latest - 7i)` inclusive. Each bucket emits
/// its latest-date member. Output is sorted ascending.
fn aggregateWeeklyRolling(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
) ![]TimelinePoint {
var picked: std.ArrayList(TimelinePoint) = .empty;
errdefer picked.deinit(allocator);
const last_date = points[points.len - 1].as_of_date;
// Bucket index i covers dates in [last - 7i - 6, last - 7i].
// We scan points newest-first. For each point, compute its bucket
// relative to `last`; keep the first (i.e. newest) one we see per
// bucket.
var current_bucket: i32 = -1;
var i: usize = points.len;
while (i > 0) {
i -= 1;
const p = points[i];
const age_days = last_date.days - p.as_of_date.days;
if (age_days < 0) continue; // shouldn't happen (series is sorted)
const bucket: i32 = @divFloor(age_days, 7);
if (bucket != current_bucket) {
try picked.append(allocator, p);
current_bucket = bucket;
}
}
// picked is newest-first; reverse to ascending.
std.mem.reverse(TimelinePoint, picked.items);
return picked.toOwnedSlice(allocator);
}
/// Group by (year, month); emit the latest-date member of each group.
fn aggregateMonthly(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
) ![]TimelinePoint {
var picked: std.ArrayList(TimelinePoint) = .empty;
errdefer picked.deinit(allocator);
// Scan ascending (points are already sorted). For each (year, month)
// key, keep updating the "representative" point until the key
// changes; then commit the previous one.
var cur_year: i16 = 0;
var cur_month: u8 = 0;
var cur_point: ?TimelinePoint = null;
for (points) |p| {
const y = p.as_of_date.year();
const m = p.as_of_date.month();
if (cur_point == null) {
cur_year = y;
cur_month = m;
cur_point = p;
continue;
}
if (y == cur_year and m == cur_month) {
cur_point = p; // same month; keep the latest
} else {
try picked.append(allocator, cur_point.?);
cur_year = y;
cur_month = m;
cur_point = p;
}
}
if (cur_point) |p| try picked.append(allocator, p);
return picked.toOwnedSlice(allocator);
}
// ── Cascading (multi-tier) aggregation ───────────────────────
/// One tier in the cascading view of recent history. Tag names
/// double as display labels — use `@tagName(t)` directly when
/// rendering.
pub const Tier = enum {
daily,
weekly,
monthly,
quarterly,
yearly,
};
/// One bucket in the cascading view. `representative_date` is
/// the date of the latest data point inside the bucket — the row
/// "represents" that point's values. `bucket_start` / `bucket_end`
/// describe the bucket's calendar range.
///
/// `series_slice` is a non-owning view into the `series` passed
/// to `aggregateCascading` — the points that fell inside this
/// bucket's date range. Drilldown via `childBuckets` walks the
/// parent's slice directly. Empty buckets get an empty slice.
///
/// Lifetime: the slice borrows from the caller-owned series,
/// which must outlive the `TieredSeries` and any drill-down
/// calls. In practice the series lives in `LoadedTimeline` (TUI)
/// or a local arena (CLI), both of which dominate any
/// `TieredSeries` produced from them.
pub const TierBucket = struct {
tier: Tier,
bucket_start: Date,
bucket_end: Date,
representative_date: Date,
liquid: f64,
illiquid: f64,
net_worth: f64,
/// True when every data point in the bucket came from
/// imported_values (no native snapshot landed inside it).
/// Renderers use this to gate `—` for illiquid/net_worth
/// cells and to skip plotting illiquid/net_worth in graphs.
imported_only: bool,
/// The contiguous slice of the source series that fell in
/// this bucket's date range. Empty for synthetic buckets
/// that span a date range with no data points.
series_slice: []const TimelinePoint,
};
/// Owned cascading-view result. Buckets are concatenated across
/// tiers in newest-first order: daily buckets, then weekly, then
/// monthly, quarterly, yearly. Tier transitions are inferred by
/// walking the slice and watching `bucket.tier` change.
pub const TieredSeries = struct {
buckets: []TierBucket,
allocator: std.mem.Allocator,
pub fn deinit(self: TieredSeries) void {
self.allocator.free(self.buckets);
}
};
/// Per-bucket delta ("change since the older neighbor").
///
/// Buckets in `TieredSeries` are stored newest-first. The
/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`.
/// `delta_*` on the OLDEST bucket (last in the slice) is null
/// — there's no older neighbor to compare against.
///
/// `delta_illiquid` and `delta_net_worth` are also null when
/// either neighbor is `imported_only` (imported_values doesn't
/// carry illiquid, so a Δ across an imported boundary is
/// meaningless).
pub const BucketDelta = struct {
delta_liquid: ?f64,
delta_illiquid: ?f64,
delta_net_worth: ?f64,
};
/// Compute per-bucket deltas for a `TieredSeries`. Returns a
/// slice the same length as `series.buckets`, parallel-indexed.
///
/// `delta_*` semantics: Δ on bucket `i` is `buckets[i].value -
/// buckets[i+1].value` (current minus older neighbor). Newest-
/// first iteration: positive Δ on a row = "the portfolio was
/// higher on this date than at the older neighbor's date" =
/// "we gained going forward in time." Matches the existing
/// `computeRowDeltas` convention for the flat-resolution table.
pub fn computeBucketDeltas(
allocator: std.mem.Allocator,
buckets: []const TierBucket,
) ![]BucketDelta {
const out = try allocator.alloc(BucketDelta, buckets.len);
for (buckets, 0..) |b, i| {
const older_idx = i + 1;
if (older_idx >= buckets.len) {
out[i] = .{ .delta_liquid = null, .delta_illiquid = null, .delta_net_worth = null };
continue;
}
const older = buckets[older_idx];
const dl: ?f64 = b.liquid - older.liquid;
// Illiquid / net_worth Δ is undefined when either
// neighbor lacks the data (imported_only buckets).
const cross_imported = b.imported_only or older.imported_only;
const di: ?f64 = if (cross_imported) null else b.illiquid - older.illiquid;
const dn: ?f64 = if (cross_imported) null else b.net_worth - older.net_worth;
out[i] = .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn };
}
return out;
}
/// Build the cascading view from a date-ascending series.
/// `as_of` is the reference date — the daily tier covers
/// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date`
/// for the typical case; pin a deterministic value in tests.
/// Per AGENTS.md: named `as_of` (not `today`) because callers
/// can legitimately pass any date — for back-dated views, the
/// reference is whatever the user asked for, not the calendar
/// day.
///
/// The tier coverage rules implement the user-facing intent:
/// always show at least one bucket of each granularity that has
/// any data, and never skip a tier when transitioning from finer
/// to coarser.
///
/// **Daily:** every point in `[as_of.subDays(13), as_of]`.
///
/// **Weekly:** weeks (Monday→Sunday) ending strictly before the
/// daily tier's earliest covered date, going back 4 weeks. Buckets
/// with zero data are skipped.
///
/// **Monthly:** calendar months ending before the weekly tier's
/// earliest covered date, walking back to Jan 1 of `as_of.year()`.
/// If that's fewer than 3 months, extend backward into the prior
/// year so at least 3 months are emitted (when data exists).
///
/// **Quarterly:** the most recent prior calendar year (typically
/// `as_of.year() - 1`). Up to 4 quarters newest-first.
///
/// **Yearly:** every calendar year before the quarterly tier's
/// year that has any data, newest-first.
pub fn aggregateCascading(
allocator: std.mem.Allocator,
series: []const TimelinePoint,
as_of: Date,
) !TieredSeries {
if (series.len == 0) {
return .{ .buckets = &.{}, .allocator = allocator };
}
// Compute the tier boundaries.
//
// Daily: every point in `[daily_start, as_of]`. Each becomes
// its own per-point bucket (no aggregation).
// Weekly: 4 buckets ending at `weekly_end_initial`, walking
// back 7 days at a time.
// Monthly: calendar months of `as_of.year()`, from Jan to
// `monthly_boundary.month()`. Skipped when the boundary
// falls before Jan of the as-of year.
// Quarterly: 4 calendar quarters of `as_of.year() - 1`.
// Yearly: every calendar year before `quarterly_year`,
// from `series[0].as_of_date.year()` up to `quarterly_year - 1`.
//
// We pre-build a flat frame of non-daily buckets in
// chronological (oldest-first) order. Then we make a single
// forward pass through `series`, advancing a cursor in the
// frame as point dates cross bucket boundaries. Each bucket
// remembers its latest in-range point.
const daily_start = as_of.addDays(-13);
const weekly_end_initial = daily_start.addDays(-1);
const monthly_boundary = weekly_end_initial.addDays(-28);
const as_of_year = as_of.year();
const quarterly_year: i16 = as_of_year - 1;
// ── Build the non-daily frame, oldest-first ──────────────
//
// Order: yearly (earliest..latest) → quarterly (Q1..Q4) →
// monthly (Jan..boundary month) → weekly (oldest..newest of
// the 4 weeks before the daily tier).
//
// This keeps frame entries strictly date-ascending. As we
// walk the series (also date-ascending), the bucket cursor
// only ever moves forward.
var frame: std.ArrayList(BucketFrame) = .empty;
defer frame.deinit(allocator);
// Yearly buckets.
{
const first_y = series[0].as_of_date.year();
if (first_y < quarterly_year) {
var y = first_y;
while (y < quarterly_year) : (y += 1) {
try frame.append(allocator, .{
.tier = .yearly,
.bucket_start = Date.fromYmd(y, 1, 1),
.bucket_end = Date.fromYmd(y, 12, 31),
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
}
}
// Quarterly buckets — Q1..Q4 of quarterly_year.
{
var q: u8 = 1;
while (q <= 4) : (q += 1) {
const start_month: u8 = (q - 1) * 3 + 1;
const end_month: u8 = q * 3;
try frame.append(allocator, .{
.tier = .quarterly,
.bucket_start = Date.fromYmd(quarterly_year, start_month, 1),
.bucket_end = Date.lastDayOfMonth(quarterly_year, end_month),
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
}
// Monthly buckets — Jan..monthly_boundary.month() of
// as_of_year. Skip entirely if the boundary precedes the
// year (e.g. very early in January).
if (monthly_boundary.year() == as_of_year) {
const max_month = monthly_boundary.month();
var m: u8 = 1;
while (m <= max_month) : (m += 1) {
try frame.append(allocator, .{
.tier = .monthly,
.bucket_start = Date.fromYmd(as_of_year, m, 1),
.bucket_end = Date.lastDayOfMonth(as_of_year, m),
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
}
// Weekly buckets — 4 weeks ending at weekly_end_initial.
// Build oldest-first to maintain frame ascending order.
{
var w: i32 = 3;
while (w >= 0) : (w -= 1) {
const end = weekly_end_initial.addDays(-7 * w);
const start = end.addDays(-6);
try frame.append(allocator, .{
.tier = .weekly,
.bucket_start = start,
.bucket_end = end,
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
if (w == 0) break;
}
}
// ── Single forward pass through the series ───────────────
//
// Maintain `cursor` into `frame`. For each point:
// - If point.date > as_of: skip (future-dated; rare).
// - If point.date >= daily_start: daily tier; remember
// for later (we emit dailies newest-first at the end).
// - Else advance cursor while point.date > frame[cursor].bucket_end.
// If point.date >= frame[cursor].bucket_start, update
// that bucket's `latest`, `any_snapshot`, and series-
// index range. Else the point falls in a gap between
// frame entries (rare; e.g. older than the earliest
// yearly bucket) — drop it.
//
// Each frame bucket's `series_start` / `series_end` end up
// forming the half-open slice of `series` that fell in its
// calendar range. `series_start` is set on the first hit;
// `series_end` is bumped to `idx + 1` on every hit. Empty
// buckets keep `series_start == series_end == 0`, which is
// a benign empty slice.
const DailyPoint = struct { p: TimelinePoint, idx: usize };
var daily_points: std.ArrayList(DailyPoint) = .empty;
defer daily_points.deinit(allocator);
var cursor: usize = 0;
for (series, 0..) |p, idx| {
if (p.as_of_date.days > as_of.days) continue;
if (p.as_of_date.days >= daily_start.days) {
try daily_points.append(allocator, .{ .p = p, .idx = idx });
continue;
}
// Advance the cursor until the current frame entry could
// contain `p`.
while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) {
cursor += 1;
}
if (cursor >= frame.items.len) break; // no more buckets ahead
const fb = &frame.items[cursor];
if (p.as_of_date.days < fb.bucket_start.days) {
// Point is in a gap between buckets (e.g. point falls
// in the year before the earliest yearly bucket — but
// by construction yearly starts at series[0].year, so
// this only happens for points that didn't fit any
// tier's date range). Skip.
continue;
}
if (fb.latest == null) fb.series_start = idx;
fb.series_end = idx + 1;
fb.latest = p;
if (p.source == .snapshot) fb.any_snapshot = true;
}
// ── Assemble output, newest-first ────────────────────────
var out: std.ArrayList(TierBucket) = .empty;
errdefer out.deinit(allocator);
// Daily points: collected oldest-first; emit newest-first.
var di: usize = daily_points.items.len;
while (di > 0) {
di -= 1;
const dp = daily_points.items[di];
try out.append(allocator, .{
.tier = .daily,
.bucket_start = dp.p.as_of_date,
.bucket_end = dp.p.as_of_date,
.representative_date = dp.p.as_of_date,
.liquid = dp.p.liquid,
.illiquid = dp.p.illiquid,
.net_worth = dp.p.net_worth,
.imported_only = dp.p.source == .imported,
.series_slice = series[dp.idx .. dp.idx + 1],
});
}
// Non-daily buckets: frame is oldest-first; emit newest-first.
var fi: usize = frame.items.len;
while (fi > 0) {
fi -= 1;
const fb = frame.items[fi];
const p = fb.latest orelse continue;
try out.append(allocator, .{
.tier = fb.tier,
.bucket_start = fb.bucket_start,
.bucket_end = fb.bucket_end,
.representative_date = p.as_of_date,
.liquid = p.liquid,
.illiquid = p.illiquid,
.net_worth = p.net_worth,
.imported_only = !fb.any_snapshot,
.series_slice = series[fb.series_start..fb.series_end],
});
}
return .{ .buckets = try out.toOwnedSlice(allocator), .allocator = allocator };
}
/// Mutable bucket-accumulator state used during the single
/// forward pass in `aggregateCascading` and `childBuckets`.
/// `latest` holds the most recent in-range point seen so far;
/// `any_snapshot` flips on the first snapshot-sourced point
/// that lands in this bucket. `series_start` / `series_end`
/// track the half-open index range into whatever series was
/// being walked at the time — used to construct the bucket's
/// final `series_slice` at emit time.
const BucketFrame = struct {
tier: Tier,
bucket_start: Date,
bucket_end: Date,
latest: ?TimelinePoint,
any_snapshot: bool,
series_start: usize,
series_end: usize,
};
// ── Hierarchical drill-down ──────────────────────────────────
//
// The cascading view's recent-snapshots table renders each
// bucket as its own row; clicking a bucket reveals its child
// buckets at the next-finer tier (a yearly bucket reveals
// quarterlies, a quarterly reveals monthlies, etc.).
/// Return the next-finer tier for `tier`, or null if there's no
/// finer tier (daily is the leaf).
pub fn finerTier(tier: Tier) ?Tier {
return switch (tier) {
.yearly => .quarterly,
.quarterly => .monthly,
.monthly => .weekly,
.weekly => .daily,
.daily => null,
};
}
/// Render a tier-aware label for a bucket. Caller passes the
/// bucket's tier and `bucket_start` date; the function emits
/// the appropriate display label into `buf` and returns a
/// slice of it. Shared by CLI and TUI cascading renderers.
///
/// - daily: `YYYY-MM-DD`
/// - weekly: `W of YYYY-MM-DD` (start-of-week)
/// - monthly: `MMM YYYY`
/// - quarterly: `QN YYYY`
/// - yearly: `YYYY`
///
/// `buf` should be at least 16 bytes for the longest label
/// ("W of YYYY-MM-DD" = 15). Returns `"?"` if `bufPrint` fails.
pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 {
return switch (tier) {
.daily => std.fmt.bufPrint(buf, "{f}", .{bucket_start}) catch "?",
.weekly => std.fmt.bufPrint(buf, "W of {f}", .{bucket_start}) catch "?",
.monthly => std.fmt.bufPrint(buf, "{s} {d}", .{ Date.monthShort(bucket_start.month()), bucket_start.year() }) catch "?",
.quarterly => std.fmt.bufPrint(buf, "Q{d} {d}", .{ ((bucket_start.month() - 1) / 3) + 1, bucket_start.year() }) catch "?",
.yearly => std.fmt.bufPrint(buf, "{d}", .{bucket_start.year()}) catch "?",
};
}
/// Build the child buckets contained inside `parent`. Returns
/// an owned slice, newest-first. Empty slice when `parent.tier
/// == .daily` (no children) or no data falls inside `parent`'s
/// date range.
///
/// The granularity of children is determined by `finerTier`:
/// yearly → quarterly children (Q4..Q1 of that year)
/// quarterly → monthly children (last month..first month)
/// monthly → weekly children (calendar-aligned weeks ending
/// within the month, newest-first)
/// weekly → daily children (every data point in the 7-day range)
pub fn childBuckets(
allocator: std.mem.Allocator,
parent: TierBucket,
) ![]TierBucket {
var out: std.ArrayList(TierBucket) = .empty;
errdefer out.deinit(allocator);
// Daily parents have no children.
if (parent.tier == .daily) return out.toOwnedSlice(allocator);
// The parent's contents are already pinned in
// `parent.series_slice` — recorded by `aggregateCascading`
// when the bucket was emitted. No re-scan required.
const sub = parent.series_slice;
// Weekly parent → daily children. Each in-range point becomes
// its own bucket. Walk `sub` newest-first directly.
if (parent.tier == .weekly) {
var i: usize = sub.len;
while (i > 0) {
i -= 1;
const p = sub[i];
try out.append(allocator, .{
.tier = .daily,
.bucket_start = p.as_of_date,
.bucket_end = p.as_of_date,
.representative_date = p.as_of_date,
.liquid = p.liquid,
.illiquid = p.illiquid,
.net_worth = p.net_worth,
.imported_only = p.source == .imported,
.series_slice = sub[i .. i + 1],
});
}
return out.toOwnedSlice(allocator);
}
// Monthly / quarterly / yearly parents: build a frame of
// children in chronological (oldest-first) order, walk the
// parent's `sub` slice once, bin each point into the current
// child via a forward-only cursor. Emit non-empty buckets
// newest-first at the end.
var frame: std.ArrayList(BucketFrame) = .empty;
defer frame.deinit(allocator);
const child_tier = finerTier(parent.tier).?;
switch (parent.tier) {
.monthly => {
// Weekly children: 7-day buckets ending at the last
// day of the parent month, walking back. Build them
// in oldest-first order so the frame stays ascending.
const month_start = parent.bucket_start;
const month_end = parent.bucket_end;
// Generate week boundaries newest-first (anchored at
// month_end) into a temporary, then reverse into the
// frame.
var weeks_newest_first: std.ArrayList(struct { start: Date, end: Date }) = .empty;
defer weeks_newest_first.deinit(allocator);
var bucket_end_w = month_end;
while (bucket_end_w.days >= month_start.days) {
const bucket_start_raw = bucket_end_w.addDays(-6);
const bucket_start_w = if (bucket_start_raw.days < month_start.days) month_start else bucket_start_raw;
try weeks_newest_first.append(allocator, .{ .start = bucket_start_w, .end = bucket_end_w });
if (bucket_start_w.days <= month_start.days) break;
bucket_end_w = bucket_start_w.addDays(-1);
}
// Reverse into frame (oldest-first).
var wi: usize = weeks_newest_first.items.len;
while (wi > 0) {
wi -= 1;
const w = weeks_newest_first.items[wi];
try frame.append(allocator, .{
.tier = .weekly,
.bucket_start = w.start,
.bucket_end = w.end,
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
},
.quarterly => {
// Monthly children: 3 calendar months in the parent
// quarter, oldest-first.
const start_month = parent.bucket_start.month();
const year = parent.bucket_start.year();
var off: u8 = 0;
while (off < 3) : (off += 1) {
const month: u8 = start_month + off;
try frame.append(allocator, .{
.tier = .monthly,
.bucket_start = Date.fromYmd(year, month, 1),
.bucket_end = Date.lastDayOfMonth(year, month),
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
},
.yearly => {
// Quarterly children: Q1..Q4 of the parent year,
// oldest-first.
const year = parent.bucket_start.year();
var q: u8 = 1;
while (q <= 4) : (q += 1) {
const start_month: u8 = (q - 1) * 3 + 1;
const end_month: u8 = q * 3;
try frame.append(allocator, .{
.tier = .quarterly,
.bucket_start = Date.fromYmd(year, start_month, 1),
.bucket_end = Date.lastDayOfMonth(year, end_month),
.latest = null,
.any_snapshot = false,
.series_start = 0,
.series_end = 0,
});
}
},
// Already handled above.
.daily, .weekly => unreachable,
}
// Single forward pass over the parent's slice. No "skip
// until enter / break when leave" — every point in `sub`
// is by construction inside the parent's date range.
//
// Frame indices (`series_start`/`series_end`) are recorded
// *relative to `sub`* during the pass, then sliced into
// `sub` at emit time so each child's `series_slice` is a
// sub-view of the parent's slice.
var cursor: usize = 0;
for (sub, 0..) |p, sub_idx| {
// Advance the child-cursor until the current frame entry
// contains this point. Frame entries are non-overlapping
// and oldest-first within the parent range.
while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) {
cursor += 1;
}
if (cursor >= frame.items.len) break;
const fb = &frame.items[cursor];
if (p.as_of_date.days < fb.bucket_start.days) continue; // gap (shouldn't happen for these tiers)
if (fb.latest == null) fb.series_start = sub_idx;
fb.series_end = sub_idx + 1;
fb.latest = p;
if (p.source == .snapshot) fb.any_snapshot = true;
}
// Emit non-empty children newest-first.
var fi: usize = frame.items.len;
while (fi > 0) {
fi -= 1;
const fb = frame.items[fi];
const p = fb.latest orelse continue;
try out.append(allocator, .{
.tier = child_tier,
.bucket_start = fb.bucket_start,
.bucket_end = fb.bucket_end,
.representative_date = p.as_of_date,
.liquid = p.liquid,
.illiquid = p.illiquid,
.net_worth = p.net_worth,
.imported_only = !fb.any_snapshot,
.series_slice = sub[fb.series_start..fb.series_end],
});
}
return out.toOwnedSlice(allocator);
}
// ── Tests ────────────────────────────────────────────────────
//
// Pure compute — every function here can be exercised with fixture
// structs. No IO, no writer, no colors.
const testing = std.testing;
/// Populate `buf` with the three total rows and return a minimal
/// Snapshot fixture for tests. Each test supplies its own `buf` (three
/// `TotalRow`s on the stack) so storage doesn't alias across calls.
fn fixtureSnapshot(
buf: *[3]snapshot.TotalRow,
y: i16,
m: u8,
d: u8,
net_worth: f64,
liquid: f64,
illiquid: f64,
) snapshot.Snapshot {
buf[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };
buf[1] = .{ .kind = "total", .scope = "liquid", .value = liquid };
buf[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };
return .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = Date.fromYmd(y, m, d),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = buf,
.tax_types = &.{},
.accounts = &.{},
.lots = &.{},
};
}
test "snapshotToPoint: extracts the three totals" {
var buf: [3]snapshot.TotalRow = undefined;
const snap = fixtureSnapshot(&buf, 2026, 4, 17, 1000, 800, 200);
const p = try snapshotToPoint(testing.allocator, snap);
defer p.deinit(testing.allocator);
try testing.expect(p.as_of_date.eql(Date.fromYmd(2026, 4, 17)));
try testing.expectEqual(@as(f64, 1000), p.net_worth);
try testing.expectEqual(@as(f64, 800), p.liquid);
try testing.expectEqual(@as(f64, 200), p.illiquid);
try testing.expectEqual(@as(usize, 0), p.accounts.len);
try testing.expectEqual(@as(usize, 0), p.tax_types.len);
}
test "snapshotToPoint: missing totals default to zero" {
// Snapshot with empty totals slice — nothing at all to extract.
const snap: snapshot.Snapshot = .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = Date.fromYmd(2026, 4, 17),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = &.{},
.tax_types = &.{},
.accounts = &.{},
.lots = &.{},
};
const p = try snapshotToPoint(testing.allocator, snap);
defer p.deinit(testing.allocator);
try testing.expectEqual(@as(f64, 0), p.net_worth);
try testing.expectEqual(@as(f64, 0), p.liquid);
try testing.expectEqual(@as(f64, 0), p.illiquid);
}
test "snapshotToPoint: propagates accounts and tax_types" {
const accts = [_]snapshot.AccountRow{
.{ .kind = "account", .name = "Emil Roth", .value = 1000 },
.{ .kind = "account", .name = "Kelly IRA", .value = 500 },
};
const taxes = [_]snapshot.TaxTypeRow{
.{ .kind = "tax_type", .label = "Taxable", .value = 800 },
};
const totals = [_]snapshot.TotalRow{
.{ .kind = "total", .scope = "net_worth", .value = 1500 },
};
const snap: snapshot.Snapshot = .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = Date.fromYmd(2026, 4, 17),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = @constCast(&totals),
.tax_types = @constCast(&taxes),
.accounts = @constCast(&accts),
.lots = &.{},
};
const p = try snapshotToPoint(testing.allocator, snap);
defer p.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), p.accounts.len);
try testing.expectEqualStrings("Emil Roth", p.accounts[0].name);
try testing.expectEqual(@as(f64, 1000), p.accounts[0].value);
try testing.expectEqual(@as(usize, 1), p.tax_types.len);
try testing.expectEqualStrings("Taxable", p.tax_types[0].name);
}
test "buildSeries: empty input" {
const series = try buildSeries(testing.allocator, &.{});
defer series.deinit();
try testing.expectEqual(@as(usize, 0), series.points.len);
}
test "buildSeries: sorts ascending by date" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 20, 3000, 0, 0),
fixtureSnapshot(&b2, 2026, 4, 17, 1000, 0, 0),
fixtureSnapshot(&b3, 2026, 4, 18, 2000, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
try testing.expectEqual(@as(usize, 3), series.points.len);
try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2026, 4, 17)));
try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2026, 4, 18)));
try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2026, 4, 20)));
try testing.expectEqual(@as(f64, 1000), series.points[0].net_worth);
try testing.expectEqual(@as(f64, 3000), series.points[2].net_worth);
}
test "buildMergedSeries: empty inputs" {
const series = try buildMergedSeries(testing.allocator, &.{}, &.{});
defer series.deinit();
try testing.expectEqual(@as(usize, 0), series.points.len);
}
test "buildMergedSeries: imported only" {
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
.{ .date = Date.fromYmd(2014, 7, 10), .liquid = 1_273_000 },
};
const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
defer series.deinit();
try testing.expectEqual(@as(usize, 2), series.points.len);
try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3)));
try testing.expectEqual(@as(f64, 1_280_000), series.points[0].liquid);
try testing.expectEqual(@as(f64, 0), series.points[0].illiquid);
try testing.expectEqual(@as(f64, 1_280_000), series.points[0].net_worth);
try testing.expect(series.points[0].source == .imported);
}
test "buildMergedSeries: snapshots only" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000),
fixtureSnapshot(&b2, 2026, 4, 21, 9_100_000, 8_100_000, 1_000_000),
};
const series = try buildMergedSeries(testing.allocator, &snaps, &.{});
defer series.deinit();
try testing.expectEqual(@as(usize, 2), series.points.len);
try testing.expect(series.points[0].source == .snapshot);
try testing.expectEqual(@as(f64, 1_000_000), series.points[0].illiquid);
}
test "buildMergedSeries: snapshot wins on overlap" {
var b1: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000),
};
const imp = [_]ImportedHistoryPoint{
// Overlapping date — snapshot wins.
.{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 },
// Non-overlapping — kept.
.{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 },
};
const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
defer series.deinit();
try testing.expectEqual(@as(usize, 2), series.points.len);
// Sorted ascending: 2025-05-25 first, then 2025-06-01.
try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2025, 5, 25)));
try testing.expect(series.points[0].source == .imported);
try testing.expectEqual(@as(f64, 4_300_000), series.points[0].liquid);
try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2025, 6, 1)));
try testing.expect(series.points[1].source == .snapshot);
// Snapshot value, not imported value.
try testing.expectEqual(@as(f64, 4_500_000), series.points[1].liquid);
try testing.expectEqual(@as(f64, 500_000), series.points[1].illiquid);
}
test "buildMergedSeries: result is sorted ascending" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000),
fixtureSnapshot(&b2, 2014, 8, 1, 1_300_000, 1_300_000, 0),
};
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2020, 1, 15), .liquid = 3_000_000 },
.{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
};
const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
defer series.deinit();
try testing.expectEqual(@as(usize, 4), series.points.len);
try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3)));
try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2014, 8, 1)));
try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2020, 1, 15)));
try testing.expect(series.points[3].as_of_date.eql(Date.fromYmd(2026, 4, 20)));
}
test "filterByDate: inclusive bounds" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
var b4: [3]snapshot.TotalRow = undefined;
var b5: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
fixtureSnapshot(&b4, 2026, 4, 20, 4, 0, 0),
fixtureSnapshot(&b5, 2026, 4, 21, 5, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const kept = try filterByDate(
testing.allocator,
series.points,
Date.fromYmd(2026, 4, 18),
Date.fromYmd(2026, 4, 20),
);
defer testing.allocator.free(kept);
try testing.expectEqual(@as(usize, 3), kept.len);
try testing.expectEqual(@as(f64, 2), kept[0].net_worth);
try testing.expectEqual(@as(f64, 4), kept[2].net_worth);
}
test "filterByDate: null since leaves left end open" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const kept = try filterByDate(testing.allocator, series.points, null, Date.fromYmd(2026, 4, 18));
defer testing.allocator.free(kept);
try testing.expectEqual(@as(usize, 2), kept.len);
}
test "filterByDate: null until leaves right end open" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2026, 4, 18), null);
defer testing.allocator.free(kept);
try testing.expectEqual(@as(usize, 2), kept.len);
}
test "filterByDate: both bounds null returns everything" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const kept = try filterByDate(testing.allocator, series.points, null, null);
defer testing.allocator.free(kept);
try testing.expectEqual(@as(usize, 2), kept.len);
}
test "filterByDate: out-of-range bounds return empty" {
var b1: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2030, 1, 1), null);
defer testing.allocator.free(kept);
try testing.expectEqual(@as(usize, 0), kept.len);
}
test "extractMetric: net_worth / liquid / illiquid" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200),
fixtureSnapshot(&b2, 2026, 4, 18, 2000, 1500, 500),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
inline for (.{
.{ Metric.net_worth, [_]f64{ 1000, 2000 } },
.{ Metric.liquid, [_]f64{ 800, 1500 } },
.{ Metric.illiquid, [_]f64{ 200, 500 } },
}) |tc| {
const out = try extractMetric(testing.allocator, series.points, tc[0]);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expectEqual(tc[1][0], out[0].value);
try testing.expectEqual(tc[1][1], out[1].value);
}
}
test "Metric.label: stable strings" {
try testing.expectEqualStrings("Net Worth", Metric.net_worth.label());
try testing.expectEqualStrings("Liquid", Metric.liquid.label());
try testing.expectEqualStrings("Illiquid", Metric.illiquid.label());
}
test "extractNamedSeries accounts: matches + absent days emit 0" {
// Build three snapshots: day1 has account A; day2 has account B; day3 has both.
// Extracting "A" should see value on day1, 0 on day2, value on day3.
var day1_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "A", .value = 100 }};
var day2_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "B", .value = 200 }};
var day3_accts = [_]snapshot.AccountRow{
.{ .kind = "account", .name = "A", .value = 300 },
.{ .kind = "account", .name = "B", .value = 400 },
};
var t1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 100 }};
var t2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 200 }};
var t3 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 700 }};
const mk = struct {
fn f(y: i16, m: u8, d: u8, totals: []snapshot.TotalRow, accts: []snapshot.AccountRow) snapshot.Snapshot {
return .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = Date.fromYmd(y, m, d),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = totals,
.tax_types = &.{},
.accounts = accts,
.lots = &.{},
};
}
}.f;
const snaps = [_]snapshot.Snapshot{
mk(2026, 4, 17, &t1, &day1_accts),
mk(2026, 4, 18, &t2, &day2_accts),
mk(2026, 4, 19, &t3, &day3_accts),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const a_series = try extractNamedSeries(testing.allocator, series.points, .accounts, "A");
defer testing.allocator.free(a_series);
try testing.expectEqual(@as(f64, 100), a_series[0].value);
try testing.expectEqual(@as(f64, 0), a_series[1].value); // absent -> 0
try testing.expectEqual(@as(f64, 300), a_series[2].value);
}
test "extractNamedSeries tax_types: absent days emit 0" {
var taxes1 = [_]snapshot.TaxTypeRow{.{ .kind = "tax_type", .label = "Taxable", .value = 500 }};
var taxes2 = [_]snapshot.TaxTypeRow{};
var tot1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 500 }};
var tot2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 0 }};
const mk = struct {
fn f(d: u8, totals: []snapshot.TotalRow, tt: []snapshot.TaxTypeRow) snapshot.Snapshot {
return .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = Date.fromYmd(2026, 4, d),
.captured_at = 0,
.zfin_version = "test",
.stale_count = 0,
},
.totals = totals,
.tax_types = tt,
.accounts = &.{},
.lots = &.{},
};
}
}.f;
const snaps = [_]snapshot.Snapshot{
mk(17, &tot1, &taxes1),
mk(18, &tot2, &taxes2),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const s = try extractNamedSeries(testing.allocator, series.points, .tax_types, "Taxable");
defer testing.allocator.free(s);
try testing.expectEqual(@as(f64, 500), s[0].value);
try testing.expectEqual(@as(f64, 0), s[1].value);
}
test "computeStats: typical case" {
const pts = [_]MetricPoint{
.{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
.{ .date = Date.fromYmd(2026, 4, 18), .value = 900 },
.{ .date = Date.fromYmd(2026, 4, 19), .value = 1500 },
.{ .date = Date.fromYmd(2026, 4, 20), .value = 1100 },
};
const s = computeStats(&pts).?;
try testing.expectEqual(@as(f64, 1000), s.first);
try testing.expectEqual(@as(f64, 1100), s.last);
try testing.expectEqual(@as(f64, 900), s.min);
try testing.expectEqual(@as(f64, 1500), s.max);
try testing.expectEqual(@as(f64, 100), s.delta_abs);
try testing.expect(s.delta_pct != null);
try testing.expectApproxEqAbs(@as(f64, 0.10), s.delta_pct.?, 1e-9);
}
test "computeStats: empty input returns null" {
const empty: []const MetricPoint = &.{};
try testing.expect(computeStats(empty) == null);
}
test "computeStats: single point — all fields equal" {
const pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }};
const s = computeStats(&pts).?;
try testing.expectEqual(@as(f64, 5000), s.first);
try testing.expectEqual(@as(f64, 5000), s.last);
try testing.expectEqual(@as(f64, 5000), s.min);
try testing.expectEqual(@as(f64, 5000), s.max);
try testing.expectEqual(@as(f64, 0), s.delta_abs);
try testing.expectEqual(@as(f64, 0), s.delta_pct.?);
}
test "computeStats: first-zero yields null delta_pct (no div-by-zero)" {
const pts = [_]MetricPoint{
.{ .date = Date.fromYmd(2026, 4, 17), .value = 0 },
.{ .date = Date.fromYmd(2026, 4, 18), .value = 1000 },
};
const s = computeStats(&pts).?;
try testing.expectEqual(@as(f64, 1000), s.delta_abs);
try testing.expect(s.delta_pct == null);
}
test "computeStats: negative delta" {
const pts = [_]MetricPoint{
.{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
.{ .date = Date.fromYmd(2026, 4, 18), .value = 800 },
};
const s = computeStats(&pts).?;
try testing.expectEqual(@as(f64, -200), s.delta_abs);
try testing.expectApproxEqAbs(@as(f64, -0.20), s.delta_pct.?, 1e-9);
}
test "buildRollupRecords: one row per point, kind discriminator set" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200),
fixtureSnapshot(&b2, 2026, 4, 18, 1100, 850, 250),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const rows = try buildRollupRecords(testing.allocator, series.points);
defer testing.allocator.free(rows);
try testing.expectEqual(@as(usize, 2), rows.len);
try testing.expectEqualStrings("rollup", rows[0].kind);
try testing.expect(rows[0].as_of_date.eql(Date.fromYmd(2026, 4, 17)));
try testing.expectEqual(@as(f64, 1000), rows[0].net_worth);
try testing.expectEqual(@as(f64, 800), rows[0].liquid);
try testing.expectEqual(@as(f64, 200), rows[0].illiquid);
try testing.expectEqual(@as(f64, 1100), rows[1].net_worth);
}
test "buildRollupRecords: empty input produces empty slice" {
const rows = try buildRollupRecords(testing.allocator, &.{});
defer testing.allocator.free(rows);
try testing.expectEqual(@as(usize, 0), rows.len);
}
// ── pointAtOrBefore ──────────────────────────────────────────
test "pointAtOrBefore: exact / snap-backward / null" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
fixtureSnapshot(&b3, 2026, 4, 21, 1200, 800, 400), // gap over weekend
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
// Exact match
try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 18)).?.net_worth == 1100);
// Snap backward over weekend: Saturday -> Friday's snapshot
try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 19)).?.net_worth == 1100);
// Before all -> null
try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 1)) == null);
// After all -> latest
try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2099, 1, 1)).?.net_worth == 1200);
}
// ── computeWindowSet ─────────────────────────────────────────
test "computeWindowSet: empty series produces empty rows" {
const ws = try computeWindowSet(testing.allocator, &.{}, .net_worth, Date.fromYmd(2026, 4, 22));
defer ws.deinit();
try testing.expectEqual(@as(usize, 0), ws.rows.len);
}
test "computeWindowSet: 8 period rows + 1 all-time row" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1000, 500),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
defer ws.deinit();
// 8 HistoricalPeriod windows + 1 all-time
try testing.expectEqual(@as(usize, 9), ws.rows.len);
// Last row is all-time (period is null, label matches)
const all_time = ws.rows[ws.rows.len - 1];
try testing.expect(all_time.period == null);
try testing.expectEqualStrings("All-time", all_time.label);
try testing.expect(all_time.start_value.? == 1000);
try testing.expect(all_time.end_value == 1500);
try testing.expect(all_time.delta_abs.? == 500);
try testing.expectApproxEqAbs(@as(f64, 0.5), all_time.delta_pct.?, 1e-9);
}
test "computeWindowSet: not-enough-history leaves start_value/delta null" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 21, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 22, 1100, 750, 350),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
defer ws.deinit();
// First row is 1-day: anchor = 2026-04-21 (exact match). Fully populated.
try testing.expect(ws.rows[0].period == HistoricalPeriod.@"1D");
try testing.expect(ws.rows[0].anchor_date != null);
try testing.expect(ws.rows[0].delta_abs != null);
// 1-year row (index 4 in timeline_windows: 1D, 1W, 1M, YTD, 1Y, 3Y, 5Y, 10Y)
// with only 2 days of history: no anchor.
try testing.expect(ws.rows[4].period == HistoricalPeriod.@"1Y");
try testing.expect(ws.rows[4].anchor_date == null);
try testing.expect(ws.rows[4].start_value == null);
try testing.expect(ws.rows[4].delta_abs == null);
try testing.expect(ws.rows[4].delta_pct == null);
// end_value still populated
try testing.expect(ws.rows[4].end_value == 1100);
}
test "computeWindowSet: YTD anchors to Jan 1 (snaps to prior year's last close)" {
// Dec 31 2025 snapshot + one in April 2026. YTD on 2026-04-22 should
// anchor to Dec 31 2025 (pointAtOrBefore snaps Jan 1 2026 back to the
// prior-year close).
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2025, 12, 31, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1000, 500),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
defer ws.deinit();
// ytd is index 3 in timeline_windows
const ytd = ws.rows[3];
try testing.expect(ytd.period == HistoricalPeriod.ytd);
try testing.expect(ytd.anchor_date.?.eql(Date.fromYmd(2025, 12, 31)));
try testing.expect(ytd.start_value.? == 1000);
try testing.expect(ytd.delta_abs.? == 500);
}
test "computeWindowSet: liquid metric is independent of net_worth" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1100, 400),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 4, 22));
defer ws.deinit();
const all_time = ws.rows[ws.rows.len - 1];
try testing.expect(all_time.start_value.? == 700);
try testing.expect(all_time.end_value == 1100);
try testing.expect(all_time.delta_abs.? == 400);
}
// ── computeRowDeltas ─────────────────────────────────────────
test "computeRowDeltas: first row has null deltas; others populated" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
fixtureSnapshot(&b3, 2026, 4, 19, 1050, 720, 330),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const rows = try computeRowDeltas(testing.allocator, series.points);
defer testing.allocator.free(rows);
try testing.expectEqual(@as(usize, 3), rows.len);
// First row: all deltas null
try testing.expect(rows[0].d_net_worth == null);
try testing.expect(rows[0].d_liquid == null);
try testing.expect(rows[0].d_illiquid == null);
// Second row: +100 net_worth
try testing.expect(rows[1].d_net_worth.? == 100);
try testing.expect(rows[1].d_liquid.? == 50);
try testing.expect(rows[1].d_illiquid.? == 50);
// Third row: -50 net_worth
try testing.expect(rows[2].d_net_worth.? == -50);
}
test "computeRowDeltas: empty input" {
const rows = try computeRowDeltas(testing.allocator, &.{});
defer testing.allocator.free(rows);
try testing.expectEqual(@as(usize, 0), rows.len);
}
// ── selectResolution / aggregatePoints ───────────────────────
test "selectResolution: thresholds" {
const mk = struct {
fn f(day_span: i32) []TimelinePoint {
var out = std.testing.allocator.alloc(TimelinePoint, 2) catch unreachable;
out[0] = .{
.as_of_date = Date.fromYmd(2026, 1, 1),
.net_worth = 0,
.liquid = 0,
.illiquid = 0,
.accounts = &.{},
.tax_types = &.{},
};
out[1] = .{
.as_of_date = Date.fromYmd(2026, 1, 1).addDays(day_span),
.net_worth = 0,
.liquid = 0,
.illiquid = 0,
.accounts = &.{},
.tax_types = &.{},
};
return out;
}
}.f;
const p90 = mk(90);
defer testing.allocator.free(p90);
try testing.expectEqual(Resolution.daily, selectResolution(p90));
const p91 = mk(91);
defer testing.allocator.free(p91);
try testing.expectEqual(Resolution.weekly, selectResolution(p91));
const p730 = mk(730);
defer testing.allocator.free(p730);
try testing.expectEqual(Resolution.weekly, selectResolution(p730));
const p731 = mk(731);
defer testing.allocator.free(p731);
try testing.expectEqual(Resolution.monthly, selectResolution(p731));
// Single-point: daily
try testing.expectEqual(Resolution.daily, selectResolution(p90[0..1]));
}
test "aggregatePoints: daily returns a copy" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const out = try aggregatePoints(testing.allocator, series.points, .daily);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expect(out[0].net_worth == 1000);
try testing.expect(out[1].net_worth == 1100);
}
test "aggregatePoints: weekly rolling, one pick per 7-day bucket from latest" {
// Span 21 days: expect 3 buckets, one pick each.
// Generate one point per day so we can see bucket boundaries clearly.
var points_al: std.ArrayList(TimelinePoint) = .empty;
defer points_al.deinit(testing.allocator);
var i: i32 = 0;
while (i <= 20) : (i += 1) {
try points_al.append(testing.allocator, .{
.as_of_date = Date.fromYmd(2026, 4, 1).addDays(i),
.net_worth = @as(f64, @floatFromInt(1000 + i)),
.liquid = 0,
.illiquid = 0,
.accounts = &.{},
.tax_types = &.{},
});
}
const out = try aggregatePoints(testing.allocator, points_al.items, .weekly);
defer testing.allocator.free(out);
// Last date is 2026-04-21. Buckets anchored from that:
// bucket 0: days [15..21] -> pick 2026-04-21 (1020)
// bucket 1: days [08..14] -> pick 2026-04-14 (1013)
// bucket 2: days [01..07] -> pick 2026-04-07 (1006)
try testing.expectEqual(@as(usize, 3), out.len);
try testing.expect(out[0].as_of_date.eql(Date.fromYmd(2026, 4, 7)));
try testing.expect(out[0].net_worth == 1006);
try testing.expect(out[1].as_of_date.eql(Date.fromYmd(2026, 4, 14)));
try testing.expect(out[2].as_of_date.eql(Date.fromYmd(2026, 4, 21)));
try testing.expect(out[2].net_worth == 1020);
}
test "aggregatePoints: monthly picks latest snapshot in each calendar month" {
var b1: [3]snapshot.TotalRow = undefined;
var b2: [3]snapshot.TotalRow = undefined;
var b3: [3]snapshot.TotalRow = undefined;
var b4: [3]snapshot.TotalRow = undefined;
var b5: [3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&b1, 2026, 2, 5, 100, 0, 0),
fixtureSnapshot(&b2, 2026, 2, 28, 200, 0, 0), // latest Feb
fixtureSnapshot(&b3, 2026, 3, 1, 300, 0, 0),
fixtureSnapshot(&b4, 2026, 3, 31, 400, 0, 0), // latest Mar
fixtureSnapshot(&b5, 2026, 4, 10, 500, 0, 0), // only Apr entry
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const out = try aggregatePoints(testing.allocator, series.points, .monthly);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 3), out.len);
try testing.expect(out[0].as_of_date.eql(Date.fromYmd(2026, 2, 28)));
try testing.expect(out[0].net_worth == 200);
try testing.expect(out[1].as_of_date.eql(Date.fromYmd(2026, 3, 31)));
try testing.expect(out[1].net_worth == 400);
try testing.expect(out[2].as_of_date.eql(Date.fromYmd(2026, 4, 10)));
try testing.expect(out[2].net_worth == 500);
}
test "aggregatePoints: empty input returns empty slice" {
const out_d = try aggregatePoints(testing.allocator, &.{}, .daily);
defer testing.allocator.free(out_d);
try testing.expectEqual(@as(usize, 0), out_d.len);
const out_w = try aggregatePoints(testing.allocator, &.{}, .weekly);
defer testing.allocator.free(out_w);
try testing.expectEqual(@as(usize, 0), out_w.len);
const out_m = try aggregatePoints(testing.allocator, &.{}, .monthly);
defer testing.allocator.free(out_m);
try testing.expectEqual(@as(usize, 0), out_m.len);
}
test "aggregateCascading: empty series" {
const ts = try aggregateCascading(testing.allocator, &.{}, Date.fromYmd(2026, 5, 11));
defer ts.deinit();
try testing.expectEqual(@as(usize, 0), ts.buckets.len);
}
test "aggregateCascading: daily tier covers last 14 days" {
var bufs: [10][3]snapshot.TotalRow = undefined;
var snaps_arr: [10]snapshot.Snapshot = undefined;
// Snapshots from 2026-04-30 through 2026-05-09 (10 days).
snaps_arr[0] = fixtureSnapshot(&bufs[0], 2026, 4, 30, 100, 100, 0);
snaps_arr[1] = fixtureSnapshot(&bufs[1], 2026, 5, 1, 110, 110, 0);
snaps_arr[2] = fixtureSnapshot(&bufs[2], 2026, 5, 2, 120, 120, 0);
snaps_arr[3] = fixtureSnapshot(&bufs[3], 2026, 5, 3, 130, 130, 0);
snaps_arr[4] = fixtureSnapshot(&bufs[4], 2026, 5, 4, 140, 140, 0);
snaps_arr[5] = fixtureSnapshot(&bufs[5], 2026, 5, 5, 150, 150, 0);
snaps_arr[6] = fixtureSnapshot(&bufs[6], 2026, 5, 6, 160, 160, 0);
snaps_arr[7] = fixtureSnapshot(&bufs[7], 2026, 5, 7, 170, 170, 0);
snaps_arr[8] = fixtureSnapshot(&bufs[8], 2026, 5, 8, 180, 180, 0);
snaps_arr[9] = fixtureSnapshot(&bufs[9], 2026, 5, 9, 190, 190, 0);
const series = try buildSeries(testing.allocator, &snaps_arr);
defer series.deinit();
const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
defer ts.deinit();
// All 10 should land in the daily tier (days 04-30 to 05-09 are within 13 days of 05-11).
var daily_count: usize = 0;
for (ts.buckets) |b| if (b.tier == .daily) {
daily_count += 1;
};
try testing.expectEqual(@as(usize, 10), daily_count);
// Newest-first: first daily bucket is 2026-05-09.
try testing.expect(ts.buckets[0].representative_date.eql(Date.fromYmd(2026, 5, 9)));
try testing.expectEqual(@as(f64, 190), ts.buckets[0].liquid);
}
test "aggregateCascading: weekly tier emitted for older data" {
var bufs: [3][3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
// Outside daily (>13 days before 2026-05-11): should land in weekly.
fixtureSnapshot(&bufs[0], 2026, 4, 27, 100, 100, 0),
fixtureSnapshot(&bufs[1], 2026, 4, 20, 110, 110, 0),
fixtureSnapshot(&bufs[2], 2026, 4, 13, 120, 120, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
defer ts.deinit();
var weekly_count: usize = 0;
for (ts.buckets) |b| if (b.tier == .weekly) {
weekly_count += 1;
};
try testing.expect(weekly_count >= 1);
}
test "aggregateCascading: imported-only buckets flagged" {
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
.{ .date = Date.fromYmd(2014, 12, 26), .liquid = 1_400_000 },
.{ .date = Date.fromYmd(2024, 6, 1), .liquid = 5_000_000 },
};
const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
defer series.deinit();
const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
defer ts.deinit();
// Yearly buckets for 2014, 2024 should exist and be imported_only.
var found_2014 = false;
for (ts.buckets) |b| {
if (b.tier == .yearly and b.bucket_start.year() == 2014) {
found_2014 = true;
try testing.expect(b.imported_only);
try testing.expectEqual(@as(f64, 1_400_000), b.liquid);
try testing.expectEqual(@as(f64, 0), b.illiquid);
}
}
try testing.expect(found_2014);
}
test "aggregateCascading: yearly buckets are newest-first" {
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2020, 6, 1), .liquid = 3_000_000 },
.{ .date = Date.fromYmd(2018, 6, 1), .liquid = 2_500_000 },
.{ .date = Date.fromYmd(2016, 6, 1), .liquid = 2_000_000 },
.{ .date = Date.fromYmd(2014, 6, 1), .liquid = 1_500_000 },
};
const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
defer series.deinit();
const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
defer ts.deinit();
// Filter to yearly buckets and confirm descending year order.
var prev_year: ?i16 = null;
for (ts.buckets) |b| {
if (b.tier != .yearly) continue;
if (prev_year) |py| {
try testing.expect(b.bucket_start.year() < py);
}
prev_year = b.bucket_start.year();
}
}
test "aggregateCascading: monthly stays in current year only" {
var bufs: [4][3]snapshot.TotalRow = undefined;
// today = 2026-02-15. Current calendar year (2026) has data
// only for Jan and Feb. Monthly should NOT dip into 2025;
// the quarterly tier handles prior-year buckets.
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&bufs[0], 2025, 11, 28, 100, 100, 0),
fixtureSnapshot(&bufs[1], 2025, 12, 26, 110, 110, 0),
fixtureSnapshot(&bufs[2], 2026, 1, 30, 120, 120, 0),
fixtureSnapshot(&bufs[3], 2026, 2, 13, 130, 130, 0), // outside daily (today=2026-02-15, daily_start=2026-02-02), so this is weekly+
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 2, 15));
defer ts.deinit();
var has_jan_2026 = false;
var has_dec_2025 = false;
var has_q4_2025 = false;
for (ts.buckets) |b| {
if (b.tier == .monthly and b.bucket_start.year() == 2026 and b.bucket_start.month() == 1) has_jan_2026 = true;
if (b.tier == .monthly and b.bucket_start.year() == 2025) has_dec_2025 = true;
if (b.tier == .quarterly and b.bucket_start.year() == 2025 and b.bucket_start.month() == 10) has_q4_2025 = true;
}
try testing.expect(has_jan_2026);
try testing.expect(!has_dec_2025); // monthly does NOT dip into 2025
try testing.expect(has_q4_2025);
}
test "computeBucketDeltas: Δ on row i is current minus older neighbor" {
const buckets = [_]TierBucket{
.{ // Newest first.
.tier = .yearly,
.bucket_start = Date.fromYmd(2024, 1, 1),
.bucket_end = Date.fromYmd(2024, 12, 31),
.representative_date = Date.fromYmd(2024, 12, 31),
.liquid = 6_000_000,
.illiquid = 0,
.net_worth = 6_000_000,
.imported_only = true,
.series_slice = &.{},
},
.{
.tier = .yearly,
.bucket_start = Date.fromYmd(2023, 1, 1),
.bucket_end = Date.fromYmd(2023, 12, 31),
.representative_date = Date.fromYmd(2023, 12, 31),
.liquid = 5_000_000,
.illiquid = 0,
.net_worth = 5_000_000,
.imported_only = true,
.series_slice = &.{},
},
.{
.tier = .yearly,
.bucket_start = Date.fromYmd(2022, 1, 1),
.bucket_end = Date.fromYmd(2022, 12, 31),
.representative_date = Date.fromYmd(2022, 12, 31),
.liquid = 4_000_000,
.illiquid = 0,
.net_worth = 4_000_000,
.imported_only = true,
.series_slice = &.{},
},
};
const deltas = try computeBucketDeltas(testing.allocator, &buckets);
defer testing.allocator.free(deltas);
// Row 0 (2024) Δ vs row 1 (2023): +1M (gained from 2023 to 2024).
try testing.expectEqual(@as(?f64, 1_000_000), deltas[0].delta_liquid);
// Row 1 (2023) Δ vs row 2 (2022): +1M.
try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid);
// Row 2 (2022): oldest, no neighbor.
try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid);
// illiquid Δ across imported_only neighbors → null.
try testing.expectEqual(@as(?f64, null), deltas[0].delta_illiquid);
}
test "childBuckets: yearly produces 4 quarterly children" {
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2024, 2, 28), .liquid = 1_000_000 },
.{ .date = Date.fromYmd(2024, 5, 31), .liquid = 2_000_000 },
.{ .date = Date.fromYmd(2024, 8, 30), .liquid = 3_000_000 },
.{ .date = Date.fromYmd(2024, 11, 29), .liquid = 4_000_000 },
};
const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
defer series.deinit();
const parent: TierBucket = .{
.tier = .yearly,
.bucket_start = Date.fromYmd(2024, 1, 1),
.bucket_end = Date.fromYmd(2024, 12, 31),
.representative_date = Date.fromYmd(2024, 11, 29),
.liquid = 4_000_000,
.illiquid = 0,
.net_worth = 4_000_000,
.imported_only = true,
.series_slice = series.points,
};
const children = try childBuckets(testing.allocator, parent);
defer testing.allocator.free(children);
try testing.expectEqual(@as(usize, 4), children.len);
// Newest-first: Q4, Q3, Q2, Q1.
try testing.expect(children[0].tier == .quarterly);
try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 10, 1)));
try testing.expect(children[3].bucket_start.eql(Date.fromYmd(2024, 1, 1)));
// Q4's value comes from the latest 2024-11-29 datum.
try testing.expectEqual(@as(f64, 4_000_000), children[0].liquid);
}
test "childBuckets: quarterly produces 3 monthly children" {
const imp = [_]ImportedHistoryPoint{
.{ .date = Date.fromYmd(2024, 1, 31), .liquid = 1_000_000 },
.{ .date = Date.fromYmd(2024, 2, 28), .liquid = 2_000_000 },
.{ .date = Date.fromYmd(2024, 3, 31), .liquid = 3_000_000 },
};
const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
defer series.deinit();
const parent: TierBucket = .{
.tier = .quarterly,
.bucket_start = Date.fromYmd(2024, 1, 1),
.bucket_end = Date.fromYmd(2024, 3, 31),
.representative_date = Date.fromYmd(2024, 3, 31),
.liquid = 3_000_000,
.illiquid = 0,
.net_worth = 3_000_000,
.imported_only = true,
.series_slice = series.points,
};
const children = try childBuckets(testing.allocator, parent);
defer testing.allocator.free(children);
try testing.expectEqual(@as(usize, 3), children.len);
// Newest-first: Mar, Feb, Jan.
try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 3, 1)));
try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2024, 1, 1)));
}
test "childBuckets: weekly produces daily children" {
var bufs: [3][3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&bufs[0], 2026, 4, 13, 100, 100, 0),
fixtureSnapshot(&bufs[1], 2026, 4, 14, 110, 110, 0),
fixtureSnapshot(&bufs[2], 2026, 4, 15, 120, 120, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const parent: TierBucket = .{
.tier = .weekly,
.bucket_start = Date.fromYmd(2026, 4, 13),
.bucket_end = Date.fromYmd(2026, 4, 19),
.representative_date = Date.fromYmd(2026, 4, 15),
.liquid = 120,
.illiquid = 120,
.net_worth = 120,
.imported_only = false,
.series_slice = series.points,
};
const children = try childBuckets(testing.allocator, parent);
defer testing.allocator.free(children);
try testing.expectEqual(@as(usize, 3), children.len);
// Newest-first.
try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2026, 4, 15)));
try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2026, 4, 13)));
try testing.expect(children[0].tier == .daily);
}
test "childBuckets: daily has no children" {
const parent: TierBucket = .{
.tier = .daily,
.bucket_start = Date.fromYmd(2026, 5, 8),
.bucket_end = Date.fromYmd(2026, 5, 8),
.representative_date = Date.fromYmd(2026, 5, 8),
.liquid = 100,
.illiquid = 0,
.net_worth = 100,
.imported_only = false,
.series_slice = &.{},
};
const children = try childBuckets(testing.allocator, parent);
defer testing.allocator.free(children);
try testing.expectEqual(@as(usize, 0), children.len);
}
test "finerTier transitions" {
try testing.expectEqual(Tier.quarterly, finerTier(.yearly).?);
try testing.expectEqual(Tier.monthly, finerTier(.quarterly).?);
try testing.expectEqual(Tier.weekly, finerTier(.monthly).?);
try testing.expectEqual(Tier.daily, finerTier(.weekly).?);
try testing.expectEqual(@as(?Tier, null), finerTier(.daily));
}
test "annualizedFromPct: 1-year window equals delta_pct" {
// Anchor 365 days back, 10% gain. CAGR = 10% / yr exactly
// (365 days / 365.25 = 0.9993 years; close enough that the
// result is essentially +10%).
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2025, 5, 11);
const ann = annualizedFromPct(0.10, anchor, as_of).?;
try testing.expectApproxEqAbs(0.10, ann, 0.001);
}
test "annualizedFromPct: 3-year doubling yields ~26% CAGR" {
// 100% gain over 3 years = 2^(1/3) - 1 ≈ 0.2599.
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2023, 5, 11);
const ann = annualizedFromPct(1.0, anchor, as_of).?;
try testing.expectApproxEqAbs(0.2599, ann, 0.005);
}
test "annualizedFromPct: 10-year +481.49% yields ~19.27% CAGR" {
// From the user's actual data: 10Y cumulative return.
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2016, 5, 11);
const ann = annualizedFromPct(4.8149, anchor, as_of).?;
try testing.expectApproxEqAbs(0.1927, ann, 0.005);
}
test "annualizedFromPct: null delta_pct → null" {
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2025, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of));
}
test "annualizedFromPct: anchor in future → null" {
const as_of = Date.fromYmd(2026, 5, 11);
const future = Date.fromYmd(2027, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of));
}
test "annualizedFromPct: same-day anchor → null (years <= 0)" {
const as_of = Date.fromYmd(2026, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of));
}
test "annualizedFromPct: equity-going-negative case → null" {
// delta_pct = -1.5 means we lost 150% — impossible from
// positive starting equity, but defensive.
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2025, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(-1.5, anchor, as_of));
}
test "computeWindowSet: populates annualized_pct on real-ish data" {
// 4 snapshots: 2014-07-03 ($1.28M), 2020-05-11 ($4.0M),
// 2025-05-11 ($7.4M), 2026-05-11 ($8.6M).
var bufs: [4][3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&bufs[0], 2014, 7, 3, 1_280_000, 1_280_000, 0),
fixtureSnapshot(&bufs[1], 2020, 5, 11, 4_000_000, 4_000_000, 0),
fixtureSnapshot(&bufs[2], 2025, 5, 11, 7_400_000, 7_400_000, 0),
fixtureSnapshot(&bufs[3], 2026, 5, 11, 8_600_000, 8_600_000, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11));
defer ws.deinit();
// Find the 1Y row and the all-time row.
var found_1y = false;
var found_all = false;
for (ws.rows) |row| {
if (row.period) |p| {
if (p == .@"1Y") {
found_1y = true;
// 1Y: $7.4M → $8.6M = ~16.2% cumulative.
// Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days.
const ann = row.annualized_pct.?;
try testing.expectApproxEqAbs(0.1622, ann, 0.005);
}
} else {
// All-time row.
found_all = true;
// ~11.85 years, +$1.28M → +$8.6M = ~572% cumulative
// → CAGR ~17.4%
const ann = row.annualized_pct.?;
try testing.expectApproxEqAbs(0.174, ann, 0.01);
}
}
try testing.expect(found_1y);
try testing.expect(found_all);
}
test "computeWindowSet: missing anchor leaves annualized_pct null" {
// A series too short for a 5Y anchor: should produce null
// both for delta_pct and annualized_pct on that row.
var bufs: [2][3]snapshot.TotalRow = undefined;
const snaps = [_]snapshot.Snapshot{
fixtureSnapshot(&bufs[0], 2025, 1, 1, 1000, 1000, 0),
fixtureSnapshot(&bufs[1], 2026, 5, 11, 2000, 2000, 0),
};
const series = try buildSeries(testing.allocator, &snaps);
defer series.deinit();
const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11));
defer ws.deinit();
for (ws.rows) |row| {
if (row.period) |p| {
if (p == .@"5Y" or p == .@"10Y") {
try testing.expectEqual(@as(?f64, null), row.annualized_pct);
}
}
}
}