2596 lines
101 KiB
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);
|
|
}
|
|
}
|
|
}
|
|
}
|