IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
211 lines
7.1 KiB
Zig
211 lines
7.1 KiB
Zig
/// Build-time generator: reads ie_data.csv and outputs a Zig source file
|
|
/// containing the parsed Shiller annual returns as a const array.
|
|
///
|
|
/// Uses the same algorithm as the former comptime parser:
|
|
/// - Skip 8 header lines
|
|
/// - Scan each line, fast-reject non-January by checking "YYYY.01," at fixed offsets
|
|
/// - Parse cols 4 (CPI), 6 (GS10), 9 (TR Price) via CsvFieldIterator
|
|
/// - Compute year-over-year returns on the fly
|
|
/// - Skip ~11 months after each January row
|
|
const std = @import("std");
|
|
const ShillerYear = @import("shiller").ShillerYear;
|
|
|
|
pub fn main(init: std.process.Init) !void {
|
|
const allocator = init.arena.allocator();
|
|
const io = init.io;
|
|
|
|
const args = try init.minimal.args.toSlice(allocator);
|
|
if (args.len < 3) {
|
|
std.debug.print("Usage: gen_shiller <ie_data.csv> <output.zig>\n", .{});
|
|
std.process.exit(1);
|
|
}
|
|
|
|
const csv_data = try std.Io.Dir.cwd().readFileAlloc(io, args[1], allocator, .limited(10 * 1024 * 1024));
|
|
|
|
var results: [200]ShillerYear = undefined;
|
|
|
|
// Write output .zig file — just raw parallel arrays, no type dependencies.
|
|
const out_file = try std.Io.Dir.cwd().createFile(io, args[2], .{});
|
|
defer out_file.close(io);
|
|
|
|
const parsed = try parseCsv(csv_data, &results);
|
|
var out_buf: [1024]u8 = undefined;
|
|
var file_writer = out_file.writer(io, &out_buf);
|
|
const writer = &file_writer.interface;
|
|
try writer.writeAll(
|
|
\\// Auto-generated from ie_data.csv — do not edit.
|
|
\\// Regenerate: zig build (runs build/gen_shiller.zig)
|
|
\\
|
|
\\const ShillerYear = @import("shiller").ShillerYear;
|
|
\\
|
|
\\pub const data = [_]ShillerYear{
|
|
\\
|
|
);
|
|
|
|
for (parsed) |p|
|
|
try writer.print(
|
|
" .{{ .year = {d}, .sp500_total_return = {d}, .bond_total_return = {d}, .cpi_inflation = {d} }},\n",
|
|
.{ p.year, p.sp500_total_return, p.bond_total_return, p.cpi_inflation },
|
|
);
|
|
|
|
try writer.writeAll("};\n");
|
|
try writer.flush();
|
|
}
|
|
|
|
fn parseCsv(csv_data: []const u8, buffer: []ShillerYear) ![]ShillerYear {
|
|
var prev_year: u16 = 0;
|
|
var prev_tr_price: f64 = 0;
|
|
var prev_gs10: f64 = 0;
|
|
var prev_cpi: f64 = 0;
|
|
|
|
// Skip 8 header lines
|
|
var pos: usize = 0;
|
|
var newlines: usize = 0;
|
|
while (pos < csv_data.len and newlines < 8) : (pos += 1) {
|
|
if (csv_data[pos] == '\n') newlines += 1;
|
|
}
|
|
|
|
const skip_bytes: usize = 11 * 96;
|
|
|
|
var current: usize = 0;
|
|
while (pos < csv_data.len) {
|
|
const line_start = pos;
|
|
while (pos < csv_data.len and csv_data[pos] != '\n') pos += 1;
|
|
const line_end = if (pos > line_start and csv_data[pos - 1] == '\r') pos - 1 else pos;
|
|
if (pos < csv_data.len) pos += 1;
|
|
|
|
const line = csv_data[line_start..line_end];
|
|
if (line.len < 7) continue;
|
|
|
|
// Fast reject: date is "YYYY.01,"
|
|
if (line[4] != '.' or line[5] != '0' or line[6] != '1') continue;
|
|
|
|
const year = std.fmt.parseInt(u16, line[0..4], 10) catch continue;
|
|
|
|
// Parse fields via CsvFieldIterator
|
|
var col_iter = CsvFieldIterator{ .data = line };
|
|
_ = col_iter.next(); // col 0: Date
|
|
_ = col_iter.next(); // col 1: P
|
|
_ = col_iter.next(); // col 2: D
|
|
_ = col_iter.next(); // col 3: E
|
|
const cpi_field = col_iter.next() orelse continue;
|
|
_ = col_iter.next(); // col 5
|
|
const gs10_field = col_iter.next() orelse continue;
|
|
_ = col_iter.next(); // col 7
|
|
_ = col_iter.next(); // col 8
|
|
const tr_price_field = col_iter.next() orelse continue;
|
|
|
|
const cpi = parseF64WithCommas(cpi_field) orelse continue;
|
|
const gs10 = parseF64WithCommas(gs10_field) orelse continue;
|
|
const tr_price = parseF64WithCommas(tr_price_field) orelse continue;
|
|
|
|
if (cpi == 0.0 or tr_price == 0.0 or gs10 == 0.0) continue;
|
|
|
|
if (prev_year > 0 and year == prev_year + 1) {
|
|
const cpi_change = (cpi / prev_cpi) - 1.0;
|
|
const real_sp500 = (tr_price / prev_tr_price) - 1.0;
|
|
|
|
buffer[current] = .{
|
|
.year = prev_year,
|
|
.sp500_total_return = (1.0 + real_sp500) * (1.0 + cpi_change) - 1.0,
|
|
.bond_total_return = prev_gs10 / 100.0,
|
|
.cpi_inflation = cpi_change,
|
|
};
|
|
current += 1;
|
|
}
|
|
|
|
prev_year = year;
|
|
prev_tr_price = tr_price;
|
|
prev_gs10 = gs10;
|
|
prev_cpi = cpi;
|
|
|
|
// Skip ~11 months
|
|
pos = @min(pos + skip_bytes, csv_data.len);
|
|
while (pos < csv_data.len and csv_data[pos] != '\n') pos += 1;
|
|
if (pos < csv_data.len) pos += 1;
|
|
}
|
|
return buffer[0..current];
|
|
}
|
|
// --- Parsing helpers (same as shiller.zig's former comptime versions) ---
|
|
|
|
const CsvFieldIterator = struct {
|
|
data: []const u8,
|
|
pos: usize = 0,
|
|
|
|
fn next(self: *CsvFieldIterator) ?[]const u8 {
|
|
if (self.pos > self.data.len) return null;
|
|
if (self.pos == self.data.len) {
|
|
self.pos = self.data.len + 1;
|
|
return "";
|
|
}
|
|
|
|
const start = self.pos;
|
|
|
|
// Handle quoted fields
|
|
if (self.pos < self.data.len and self.data[self.pos] == '"') {
|
|
self.pos += 1;
|
|
const qstart = self.pos;
|
|
while (self.pos < self.data.len) {
|
|
if (self.data[self.pos] == '"') {
|
|
if (self.pos + 1 < self.data.len and self.data[self.pos + 1] == '"') {
|
|
self.pos += 2;
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
self.pos += 1;
|
|
}
|
|
}
|
|
const qend = self.pos;
|
|
if (self.pos < self.data.len) self.pos += 1;
|
|
if (self.pos < self.data.len and self.data[self.pos] == ',') self.pos += 1;
|
|
return self.data[qstart..qend];
|
|
}
|
|
|
|
// Unquoted field
|
|
while (self.pos < self.data.len and self.data[self.pos] != ',') {
|
|
self.pos += 1;
|
|
}
|
|
const end = self.pos;
|
|
if (self.pos < self.data.len) self.pos += 1;
|
|
return self.data[start..end];
|
|
}
|
|
};
|
|
|
|
fn parseF64WithCommas(s: []const u8) ?f64 {
|
|
if (s.len == 0) return null;
|
|
|
|
var start: usize = 0;
|
|
var end: usize = s.len;
|
|
while (start < end and s[start] == ' ') start += 1;
|
|
while (end > start and s[end - 1] == ' ') end -= 1;
|
|
if (start >= end) return null;
|
|
|
|
var negative = false;
|
|
if (s[start] == '-') {
|
|
negative = true;
|
|
start += 1;
|
|
}
|
|
|
|
var integer_part: f64 = 0;
|
|
var i = start;
|
|
while (i < end and s[i] != '.') : (i += 1) {
|
|
if (s[i] == ',') continue;
|
|
if (s[i] < '0' or s[i] > '9') return null;
|
|
integer_part = integer_part * 10.0 + @as(f64, @floatFromInt(s[i] - '0'));
|
|
}
|
|
|
|
var frac_part: f64 = 0;
|
|
if (i < end and s[i] == '.') {
|
|
i += 1;
|
|
var divisor: f64 = 10.0;
|
|
while (i < end) : (i += 1) {
|
|
if (s[i] < '0' or s[i] > '9') return null;
|
|
frac_part += @as(f64, @floatFromInt(s[i] - '0')) / divisor;
|
|
divisor *= 10.0;
|
|
}
|
|
}
|
|
|
|
const result = integer_part + frac_part;
|
|
return if (negative) -result else result;
|
|
}
|