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