389 lines
14 KiB
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);
|
|
}
|