zfin/src/data/imported_values.zig

389 lines
14 KiB
Zig

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