//! Imported portfolio history values from a manually-curated spreadsheet. //! //! Pre-dates `~/portfolio/history/` snapshot capture. Contains weekly //! `(date, liquid, expected_return, projected_retirement)` tuples //! transcribed from a third-party spreadsheet (the "K+E Funds" //! workbook for the primary portfolio, plus optional sibling //! spreadsheets for other portfolios in the same template). //! //! ## Lifecycle //! //! 1. The user exports the spreadsheet to CSV via `ssconvert -S`. //! 2. `tools/import_values.zig` (a one-shot Zig program) consumes //! the CSV and emits an `imported_values.srf` next to the //! repo's `~/portfolio/history/` snapshot directory. //! 3. This module loads that SRF for downstream consumers //! (`zfin milestones`, projection overlay, forecast-vs-actual). //! //! The SRF is a derived artifact, not a source of truth. Hand //! editing it is explicitly disallowed — the spreadsheet is the //! source, and the importer regenerates the SRF wholesale. //! //! ## Field semantics //! //! - `date` — week-ending date (typically a Friday). //! - `liquid` — total liquid net worth in USD on that date. //! Always present. //! - `expected_return` — the spreadsheet's //! `min(1y,3y,5y,10y)`-weighted return assumption used to //! derive `projected_retirement`. Optional. Decimal //! (e.g., `0.1255` = 12.55%/yr). //! - `projected_retirement` — the spreadsheet's predicted //! retirement-readiness date as of `date`. Optional. Tagged //! union: a future date, the `reached` sentinel meaning //! "model said you're already there", or absent. const std = @import("std"); const srf = @import("srf"); const Date = @import("../models/date.zig").Date; // ── Types ──────────────────────────────────────────────────── /// Projection of "when can the user retire," as captured at a /// historical observation date. /// /// `reached` is a sentinel meaning the model said "the user is /// already at retirement readiness." It can flip back to a date /// in a later week if a market correction pushes the projection /// out (rare but observed in the Jan 2026 K+E Funds data: 81 of /// the most-recent rows are `reached`, but historically the value /// has wobbled). pub const ProjectedRetirement = union(enum) { reached, date: Date, /// SRF parser hook. Accepts `reached` (case-insensitive) or /// `YYYY-MM-DD`. Any other shape is rejected. pub fn srfParse(str: []const u8) !ProjectedRetirement { if (std.ascii.eqlIgnoreCase(str, "reached")) return .reached; const d = Date.parse(str) catch return error.InvalidProjectedRetirement; return .{ .date = d }; } /// SRF serializer hook. Emits `reached` or `YYYY-MM-DD`. pub fn srfFormat( self: ProjectedRetirement, allocator: std.mem.Allocator, comptime field_name: []const u8, ) !srf.Value { _ = field_name; return switch (self) { .reached => .{ .string = try allocator.dupe(u8, "reached") }, .date => |d| blk: { const buf = try allocator.alloc(u8, 10); _ = d.format(buf[0..10]); break :blk .{ .string = buf }; }, }; } pub fn eql(a: ProjectedRetirement, b: ProjectedRetirement) bool { return switch (a) { .reached => b == .reached, .date => |ad| switch (b) { .reached => false, .date => |bd| ad.eql(bd), }, }; } }; /// One record from `imported_values.srf`. All fields besides /// `date` and `liquid` are optional (the importer omits them /// when the source spreadsheet row had no value). pub const HistoryPoint = struct { date: Date, liquid: f64, expected_return: ?f64 = null, projected_retirement: ?ProjectedRetirement = null, }; /// Owned, oldest-first slice of `HistoryPoint` records loaded /// from `imported_values.srf`. The slice's lifetime is tied to /// `allocator`; call `deinit` when done. pub const ImportedValues = struct { points: []HistoryPoint, allocator: std.mem.Allocator, /// Free the underlying slice. Records contain no allocated /// substrings (`Date` and `f64` are values; the union is a /// value). pub fn deinit(self: *ImportedValues) void { self.allocator.free(self.points); self.points = &.{}; } }; // ── Loader ─────────────────────────────────────────────────── pub const LoadError = error{ /// SRF parse failed (malformed file, missing required field, /// or otherwise unreadable). InvalidSrf, /// Two records share the same `date` field. The importer /// guarantees this can't happen for a valid output, so this /// indicates corruption or hand-editing. DuplicateDate, /// Records are not strictly date-ascending. The importer /// guarantees ascending order, so this indicates corruption /// or hand-editing. NotSorted, } || std.mem.Allocator.Error; /// Load `imported_values.srf` from the given path. /// /// Returns an `ImportedValues` with an empty slice if the file /// does not exist (treated as "no historical data available", /// not an error). All other failures return a non-null error. /// /// Caller owns the returned `ImportedValues`; call `deinit`. pub fn loadImportedValues( io: std.Io, allocator: std.mem.Allocator, path: []const u8, ) !ImportedValues { const bytes = std.Io.Dir.cwd().readFileAlloc( io, path, allocator, .limited(50 * 1024 * 1024), ) catch |err| switch (err) { error.FileNotFound => return ImportedValues{ .points = &.{}, .allocator = allocator, }, else => return err, }; defer allocator.free(bytes); return parseImportedValues(allocator, bytes); } /// Parse `imported_values.srf` bytes. Lower-level entry point — /// `loadImportedValues` is the typical call site. /// /// Validates: ascending-date order and no duplicate dates. /// String fields on each record are owned by the returned slice /// (no borrows from `bytes` — they're parsed into value-typed /// fields only). pub fn parseImportedValues( allocator: std.mem.Allocator, bytes: []const u8, ) !ImportedValues { var reader = std.Io.Reader.fixed(bytes); var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidSrf; defer it.deinit(); var points: std.ArrayList(HistoryPoint) = .empty; errdefer points.deinit(allocator); while (it.next() catch return error.InvalidSrf) |fields| { const point = fields.to(HistoryPoint) catch return error.InvalidSrf; try points.append(allocator, point); } // Validate ordering and uniqueness in one pass. if (points.items.len > 1) { var prev = points.items[0].date; for (points.items[1..]) |p| { if (p.date.eql(prev)) return error.DuplicateDate; if (p.date.lessThan(prev)) return error.NotSorted; prev = p.date; } } const owned = try points.toOwnedSlice(allocator); return ImportedValues{ .points = owned, .allocator = allocator }; } // ── Tests ──────────────────────────────────────────────────── test "ProjectedRetirement.srfParse: reached" { const v = try ProjectedRetirement.srfParse("reached"); try std.testing.expect(v == .reached); // Case-insensitive. const v2 = try ProjectedRetirement.srfParse("REACHED"); try std.testing.expect(v2 == .reached); } test "ProjectedRetirement.srfParse: date" { const v = try ProjectedRetirement.srfParse("2030-01-15"); try std.testing.expect(v == .date); try std.testing.expectEqual(@as(i16, 2030), v.date.year()); try std.testing.expectEqual(@as(u8, 1), v.date.month()); try std.testing.expectEqual(@as(u8, 15), v.date.day()); } test "ProjectedRetirement.srfParse: invalid" { try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("not a date")); try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("")); try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("2030/01/15")); } test "ProjectedRetirement.eql" { const r: ProjectedRetirement = .reached; const r2: ProjectedRetirement = .reached; const d1 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) }; const d2 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) }; const d3 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 16) }; try std.testing.expect(r.eql(r2)); try std.testing.expect(d1.eql(d2)); try std.testing.expect(!d1.eql(d3)); try std.testing.expect(!r.eql(d1)); try std.testing.expect(!d1.eql(r)); } test "parseImportedValues: minimal valid file" { const data = \\#!srfv1 \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 \\date::2014-07-10,liquid:num:1272951.94,expected_return:num:0.1206,projected_retirement::2023-11-24 \\ ; var iv = try parseImportedValues(std.testing.allocator, data); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 2), iv.points.len); try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date); try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); try std.testing.expectEqual(@as(?f64, 0.1223), iv.points[0].expected_return); try std.testing.expect(iv.points[0].projected_retirement != null); try std.testing.expect(iv.points[0].projected_retirement.? == .date); try std.testing.expectEqual( Date.fromYmd(2023, 9, 27), iv.points[0].projected_retirement.?.date, ); } test "parseImportedValues: reached sentinel" { const data = \\#!srfv1 \\date::2026-01-09,liquid:num:7912778.43,expected_return:num:0.1255,projected_retirement::reached \\ ; var iv = try parseImportedValues(std.testing.allocator, data); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 1), iv.points.len); try std.testing.expect(iv.points[0].projected_retirement != null); try std.testing.expect(iv.points[0].projected_retirement.? == .reached); } test "parseImportedValues: missing optional fields" { const data = \\#!srfv1 \\date::2015-01-01,liquid:num:1500000.00 \\ ; var iv = try parseImportedValues(std.testing.allocator, data); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 1), iv.points.len); try std.testing.expectEqual(@as(?f64, null), iv.points[0].expected_return); try std.testing.expectEqual(@as(?ProjectedRetirement, null), iv.points[0].projected_retirement); } test "parseImportedValues: empty file (header only)" { const data = "#!srfv1\n"; var iv = try parseImportedValues(std.testing.allocator, data); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 0), iv.points.len); } test "parseImportedValues: rejects duplicate dates" { const data = \\#!srfv1 \\date::2015-01-01,liquid:num:1500000.00 \\date::2015-01-01,liquid:num:1500001.00 \\ ; try std.testing.expectError( error.DuplicateDate, parseImportedValues(std.testing.allocator, data), ); } test "parseImportedValues: rejects non-monotonic dates" { const data = \\#!srfv1 \\date::2015-01-08,liquid:num:1500000.00 \\date::2015-01-01,liquid:num:1490000.00 \\ ; try std.testing.expectError( error.NotSorted, parseImportedValues(std.testing.allocator, data), ); } test "loadImportedValues: missing file returns empty" { var iv = try loadImportedValues( std.testing.io, std.testing.allocator, "/nonexistent/path/imported_values.srf", ); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 0), iv.points.len); } test "loadImportedValues: round-trip via filesystem" { const io = std.testing.io; const data = \\#!srfv1 \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 \\date::2014-07-10,liquid:num:1272951.94,projected_retirement::reached \\ ; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; const file_path = try std.fs.path.join( std.testing.allocator, &.{ dir_path, "imported_values.srf" }, ); defer std.testing.allocator.free(file_path); { var f = try std.Io.Dir.cwd().createFile(io, file_path, .{}); try f.writeStreamingAll(io, data); f.close(io); } defer std.Io.Dir.cwd().deleteFile(io, file_path) catch {}; var iv = try loadImportedValues(io, std.testing.allocator, file_path); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 2), iv.points.len); try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date); try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); try std.testing.expect(iv.points[1].projected_retirement.? == .reached); } test "parseImportedValues: comments and blank lines tolerated" { // Mirrors what tools/import_values.zig actually emits. const data = \\#!srfv1 \\# Manually-imported weekly portfolio history. \\# \\# Re-generated wholesale; do not hand-edit. \\ \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 \\date::2014-07-10,liquid:num:1272951.94 \\date::2014-07-18,liquid:num:1274083.25,projected_retirement::reached \\ ; var iv = try parseImportedValues(std.testing.allocator, data); defer iv.deinit(); try std.testing.expectEqual(@as(usize, 3), iv.points.len); try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); try std.testing.expectEqual(@as(?f64, null), iv.points[1].expected_return); try std.testing.expect(iv.points[2].projected_retirement.? == .reached); }