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%
247 lines
10 KiB
Zig
247 lines
10 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const fmt = cli.fmt;
|
|
|
|
/// CLI `analysis` command: show portfolio analysis breakdowns.
|
|
pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
|
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
|
|
defer loaded.deinit(allocator);
|
|
|
|
const portfolio = loaded.portfolio;
|
|
const positions = loaded.positions;
|
|
const syms = loaded.syms;
|
|
|
|
// Build prices from cache
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
for (positions) |pos| {
|
|
if (pos.shares <= 0) continue;
|
|
if (svc.getCachedCandles(pos.symbol)) |cs| {
|
|
defer cs.deinit();
|
|
if (cs.data.len > 0) {
|
|
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build summary via shared pipeline
|
|
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
|
|
error.NoAllocations, error.SummaryFailed => {
|
|
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer pf_data.deinit(allocator);
|
|
|
|
// Load classification metadata
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
|
|
defer allocator.free(meta_path);
|
|
|
|
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
|
|
try cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
|
|
return;
|
|
};
|
|
defer allocator.free(meta_data);
|
|
|
|
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
|
|
try cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
|
|
return;
|
|
};
|
|
defer cm.deinit();
|
|
|
|
// Load account tax type metadata (optional)
|
|
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(file_path);
|
|
defer if (acct_map_opt) |*am| am.deinit();
|
|
|
|
var result = zfin.analysis.analyzePortfolio(
|
|
allocator,
|
|
pf_data.summary.allocations,
|
|
cm,
|
|
portfolio,
|
|
pf_data.summary.total_value,
|
|
acct_map_opt,
|
|
as_of,
|
|
) catch {
|
|
try cli.stderrPrint(io, "Error computing analysis.\n");
|
|
return;
|
|
};
|
|
defer result.deinit(allocator);
|
|
|
|
const benchmark = @import("../analytics/benchmark.zig");
|
|
const split = benchmark.deriveAllocationSplit(
|
|
pf_data.summary.allocations,
|
|
cm.entries,
|
|
pf_data.summary.total_value,
|
|
portfolio.totalCash(as_of),
|
|
portfolio.totalCdFaceValue(as_of),
|
|
);
|
|
|
|
try display(result, split.stock_pct, split.bond_pct, pf_data.summary.total_value, file_path, color, out);
|
|
}
|
|
|
|
pub fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, total_value: f64, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
|
|
const label_width = fmt.analysis_label_width;
|
|
const bar_width = fmt.analysis_bar_width;
|
|
|
|
try cli.printBold(out, color, "\nPortfolio Analysis ({s})\n", .{file_path});
|
|
try out.print("========================================\n\n", .{});
|
|
|
|
// Equities vs Fixed Income summary
|
|
{
|
|
var eq_buf: [24]u8 = undefined;
|
|
var fi_buf: [24]u8 = undefined;
|
|
const eq_dollars = fmt.fmtMoneyAbs(&eq_buf, stock_pct * total_value);
|
|
const fi_dollars = fmt.fmtMoneyAbs(&fi_buf, bond_pct * total_value);
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " Equities {d:.1}% ({s}) / Fixed Income {d:.1}% ({s})\n\n", .{ stock_pct * 100, eq_dollars, bond_pct * 100, fi_dollars });
|
|
}
|
|
|
|
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
|
|
.{ .items = result.asset_class, .title = " Asset Class" },
|
|
.{ .items = result.sector, .title = " Sector (Equities)" },
|
|
.{ .items = result.geo, .title = " Geographic" },
|
|
.{ .items = result.account, .title = " By Account" },
|
|
.{ .items = result.tax_type, .title = " By Tax Type" },
|
|
};
|
|
|
|
for (sections, 0..) |sec, si| {
|
|
if (si > 0 and sec.items.len == 0) continue;
|
|
if (si > 0) try out.print("\n", .{});
|
|
// Bold + header color — reset at end of printFg clears both.
|
|
try cli.setBold(out, color);
|
|
try cli.printFg(out, color, cli.CLR_HEADER, "{s}\n", .{sec.title});
|
|
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
|
|
}
|
|
|
|
// Unclassified
|
|
if (result.unclassified.len > 0) {
|
|
try out.print("\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " Unclassified (not in metadata.srf)\n", .{});
|
|
for (result.unclassified) |sym| {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{sym});
|
|
}
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
/// Print a breakdown section with block-element bar charts to the CLI output.
|
|
pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
|
|
for (items) |item| {
|
|
var val_buf: [24]u8 = undefined;
|
|
const pct = item.weight * 100.0;
|
|
|
|
// Build bar using shared function
|
|
var bar_buf: [256]u8 = undefined;
|
|
const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width);
|
|
|
|
// Padded label
|
|
const lbl_len = @min(item.label.len, label_width);
|
|
try out.print(" ", .{});
|
|
try out.writeAll(item.label[0..lbl_len]);
|
|
if (lbl_len < label_width) {
|
|
for (0..label_width - lbl_len) |_| try out.writeAll(" ");
|
|
}
|
|
try out.writeAll(" ");
|
|
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
|
|
try out.writeAll(bar);
|
|
if (color) try fmt.ansiReset(out);
|
|
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) });
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "printBreakdownSection single item no color" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "60.0%") != null);
|
|
// No ANSI when color=false
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|
|
|
|
test "printBreakdownSection multiple items" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Stocks", .weight = 0.70, .value = 70000.0 },
|
|
.{ .label = "Bonds", .weight = 0.20, .value = 20000.0 },
|
|
.{ .label = "Cash", .weight = 0.10, .value = 10000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Stocks") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Bonds") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "70.0%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "10.0%") != null);
|
|
}
|
|
|
|
test "printBreakdownSection zero weight" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Empty", .weight = 0.0, .value = 0.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "0.0%") != null);
|
|
}
|
|
|
|
test "printBreakdownSection with color emits ANSI" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Test", .weight = 0.50, .value = 50000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, true);
|
|
const out = w.buffered();
|
|
// Should contain ANSI escape for bar color
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
|
}
|
|
|
|
test "display shows all sections" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const asset_class = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 },
|
|
.{ .label = "International", .weight = 0.40, .value = 40000.0 },
|
|
};
|
|
const sector = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Technology", .weight = 0.35, .value = 35000.0 },
|
|
};
|
|
const geo = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US", .weight = 0.80, .value = 80000.0 },
|
|
};
|
|
const empty = [_]zfin.analysis.BreakdownItem{};
|
|
const unclassified = [_][]const u8{"WEIRD"};
|
|
const result: zfin.analysis.AnalysisResult = .{
|
|
.asset_class = @constCast(&asset_class),
|
|
.sector = @constCast(§or),
|
|
.geo = @constCast(&geo),
|
|
.account = @constCast(&empty),
|
|
.tax_type = @constCast(&empty),
|
|
.unclassified = @constCast(&unclassified),
|
|
.total_value = 100000.0,
|
|
};
|
|
try display(result, 0.80, 0.20, 100000.0, "test.srf", false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Asset Class") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Sector") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Technology") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Geographic") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Unclassified") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null);
|
|
// No ANSI when color=false
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|