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%
420 lines
17 KiB
Zig
420 lines
17 KiB
Zig
//! `src/compare.zig` — portfolio comparison composition layer.
|
|
//!
|
|
//! The CLI + TUI shared "compose two points in time" module. Loads a
|
|
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings +
|
|
//! liquid total), and provides the aggregation primitive that turns
|
|
//! either a parsed snapshot or the live portfolio into the
|
|
//! `view.HoldingMap` shape the compare view consumes.
|
|
//!
|
|
//! Responsibility split:
|
|
//! - `src/history.zig` — snapshot IO + pure-domain
|
|
//! aggregation (`liquidFromSnapshot`,
|
|
//! `aggregateSnapshotAllocations` for
|
|
//! the projection view)
|
|
//! - `src/compare.zig` — compare-feature-specific
|
|
//! composition: loads a snapshot into
|
|
//! a compare-shaped `SnapshotSide`
|
|
//! (`aggregateSnapshotStocks`),
|
|
//! plus a live-portfolio aggregation
|
|
//! mirroring the same shape.
|
|
//! Lives here (not in `history.zig`)
|
|
//! because its output type is the
|
|
//! compare view's `HoldingMap` —
|
|
//! moving it would invert layers.
|
|
//! - `src/views/compare.zig` — pure view model (build CompareView
|
|
//! from two holdings maps + totals)
|
|
//! - `src/commands/compare.zig` — CLI dispatch + live-side pipeline
|
|
//! + ANSI renderer
|
|
//! - `src/tui/history_tab.zig` — TUI selection UX + styled renderer
|
|
//!
|
|
//! This module is intentionally stateless and opinion-free about where
|
|
//! the "now" side comes from. The CLI wraps a one-shot live-portfolio
|
|
//! fetch into its own private Side type; the TUI uses the App's
|
|
//! already-loaded portfolio directly. Both feed the same view model.
|
|
|
|
const std = @import("std");
|
|
const zfin = @import("root.zig");
|
|
const history = @import("history.zig");
|
|
const snapshot_model = @import("models/snapshot.zig");
|
|
const fmt = @import("format.zig");
|
|
const view = @import("views/compare.zig");
|
|
|
|
pub const Date = zfin.Date;
|
|
|
|
// ── Snapshot-side loading ────────────────────────────────────
|
|
|
|
/// A snapshot-backed side of a comparison: aggregated per-symbol
|
|
/// holdings + the liquid total for that snapshot, plus the backing
|
|
/// `LoadedSnapshot` the map's string keys borrow from.
|
|
///
|
|
/// Call `deinit` to release everything in the right order (map before
|
|
/// snapshot, because the map's keys point into the snapshot bytes).
|
|
pub const SnapshotSide = struct {
|
|
map: view.HoldingMap,
|
|
liquid: f64,
|
|
loaded: history.LoadedSnapshot,
|
|
|
|
pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void {
|
|
self.map.deinit();
|
|
self.loaded.deinit(allocator);
|
|
}
|
|
};
|
|
|
|
/// Load the snapshot for `date` from `hist_dir` and build a
|
|
/// `SnapshotSide` from its stock lots + liquid total. Returns
|
|
/// `error.FileNotFound` if the snapshot doesn't exist; the caller
|
|
/// decides how to surface that (CLI prints a "nearest date" hint; the
|
|
/// TUI only offers dates that are known to exist so it should never
|
|
/// hit this path).
|
|
pub fn loadSnapshotSide(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
hist_dir: []const u8,
|
|
date: Date,
|
|
) !SnapshotSide {
|
|
var loaded = try history.loadSnapshotAt(io, allocator, hist_dir, date);
|
|
errdefer loaded.deinit(allocator);
|
|
|
|
var map: view.HoldingMap = .init(allocator);
|
|
errdefer map.deinit();
|
|
try aggregateSnapshotStocks(&loaded.snap, &map);
|
|
|
|
return .{
|
|
.map = map,
|
|
.liquid = history.liquidFromSnapshot(&loaded.snap),
|
|
.loaded = loaded,
|
|
};
|
|
}
|
|
|
|
// ── Stock aggregation (compare-view shape) ───────────────────
|
|
|
|
/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`,
|
|
/// and group by symbol into `out_map`. Shares are summed; price is
|
|
/// taken from the first lot seen (all stock lots of a symbol share
|
|
/// the same `price` field in a given snapshot).
|
|
///
|
|
/// Lives here rather than in `history.zig` because it emits a
|
|
/// `view.HoldingMap` — a compare-view-shaped type. The projection-
|
|
/// shaped `aggregateSnapshotAllocations` (which emits the lower-level
|
|
/// `valuation.Allocation`) lives in `history.zig`.
|
|
///
|
|
/// `out_map` keys borrow from the snapshot's backing byte buffer.
|
|
/// Caller must keep the snapshot alive as long as the map is used.
|
|
pub fn aggregateSnapshotStocks(
|
|
snap: *const snapshot_model.Snapshot,
|
|
out_map: *view.HoldingMap,
|
|
) !void {
|
|
for (snap.lots) |lot| {
|
|
if (!std.mem.eql(u8, lot.security_type, "Stock")) continue;
|
|
const price = lot.price orelse continue;
|
|
if (out_map.getPtr(lot.symbol)) |h| {
|
|
h.shares += lot.shares;
|
|
// price is already set from first-seen; leave it.
|
|
} else {
|
|
try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Live-portfolio aggregation ───────────────────────────────
|
|
|
|
/// Walk the live portfolio's stock lots, group by `priceSymbol()`,
|
|
/// and look up the current price from `prices`. Mirrors the snapshot
|
|
/// aggregation so the two sides are apples-to-apples.
|
|
///
|
|
/// `out_map` keys borrow from the portfolio's lot data (via
|
|
/// `priceSymbol()`). Caller must keep the portfolio alive as long as
|
|
/// the map is used.
|
|
pub fn aggregateLiveStocks(
|
|
as_of: zfin.Date,
|
|
portfolio: *const zfin.Portfolio,
|
|
prices: *const std.StringHashMap(f64),
|
|
out_map: *view.HoldingMap,
|
|
) !void {
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type != .stock) continue;
|
|
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
|
const sym = lot.priceSymbol();
|
|
const raw_price = prices.get(sym) orelse continue;
|
|
const eff_price = lot.effectivePrice(raw_price, false);
|
|
if (out_map.getPtr(sym)) |h| {
|
|
h.shares += lot.shares;
|
|
} else {
|
|
try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const lots = [_]snapshot_model.LotRow{
|
|
.{
|
|
.symbol = "AAPL",
|
|
.lot_symbol = "AAPL",
|
|
.account = "Roth",
|
|
.security_type = "Stock",
|
|
.shares = 100,
|
|
.open_price = 120,
|
|
.cost_basis = 12000,
|
|
.value = 15000,
|
|
.price = 150.0,
|
|
.quote_date = Date.fromYmd(2024, 3, 15),
|
|
},
|
|
.{
|
|
.symbol = "AAPL",
|
|
.lot_symbol = "AAPL",
|
|
.account = "IRA",
|
|
.security_type = "Stock",
|
|
.shares = 50,
|
|
.open_price = 130,
|
|
.cost_basis = 6500,
|
|
.value = 7500,
|
|
.price = 150.0,
|
|
.quote_date = Date.fromYmd(2024, 3, 15),
|
|
},
|
|
// Cash lot — must be filtered
|
|
.{
|
|
.symbol = "CASH",
|
|
.lot_symbol = "CASH",
|
|
.account = "Roth",
|
|
.security_type = "Cash",
|
|
.shares = 1000,
|
|
.open_price = 0,
|
|
.cost_basis = 1000,
|
|
.value = 1000,
|
|
.price = null,
|
|
.quote_date = null,
|
|
},
|
|
// Another stock
|
|
.{
|
|
.symbol = "MSFT",
|
|
.lot_symbol = "MSFT",
|
|
.account = "Roth",
|
|
.security_type = "Stock",
|
|
.shares = 25,
|
|
.open_price = 380,
|
|
.cost_basis = 9500,
|
|
.value = 10000,
|
|
.price = 400.0,
|
|
.quote_date = Date.fromYmd(2024, 3, 15),
|
|
},
|
|
};
|
|
const snap = snapshot_model.Snapshot{
|
|
.meta = .{
|
|
.snapshot_version = 1,
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.captured_at = 0,
|
|
.zfin_version = "test",
|
|
.stale_count = 0,
|
|
},
|
|
.totals = &.{},
|
|
.tax_types = &.{},
|
|
.accounts = &.{},
|
|
.lots = @constCast(&lots),
|
|
};
|
|
|
|
try aggregateSnapshotStocks(&snap, &map);
|
|
|
|
try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT — not CASH
|
|
try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares);
|
|
try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price);
|
|
try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares);
|
|
}
|
|
|
|
test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" {
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
const snap_bytes =
|
|
\\#!srfv1
|
|
\\#!created=1777589000
|
|
\\kind::meta,snapshot_version:num:1,as_of_date::2024-03-15,captured_at:num:1777589000,zfin_version::test,stale_count:num:0
|
|
\\kind::total,scope::net_worth,value:num:25000
|
|
\\kind::total,scope::liquid,value:num:25000
|
|
\\kind::total,scope::illiquid,value:num:0
|
|
\\kind::lot,symbol::FOO,lot_symbol::FOO,account::Main,security_type::Stock,shares:num:100,open_price:num:120,cost_basis:num:12000,value:num:15000,price:num:150,quote_date::2024-03-15
|
|
\\kind::lot,symbol::BAR,lot_symbol::BAR,account::Main,security_type::Stock,shares:num:50,open_price:num:180,cost_basis:num:9000,value:num:10000,price:num:200,quote_date::2024-03-15
|
|
\\
|
|
;
|
|
try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes });
|
|
|
|
const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
|
|
defer testing.allocator.free(hist_dir);
|
|
|
|
var side = try loadSnapshotSide(std.testing.io, testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15));
|
|
defer side.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(f64, 25000), side.liquid);
|
|
try testing.expectEqual(@as(u32, 2), side.map.count());
|
|
try testing.expectEqual(@as(f64, 150), (side.map.get("FOO") orelse unreachable).price);
|
|
try testing.expectEqual(@as(f64, 50), (side.map.get("BAR") orelse unreachable).shares);
|
|
}
|
|
|
|
test "loadSnapshotSide: missing file propagates FileNotFound" {
|
|
const io = std.testing.io;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
|
|
defer testing.allocator.free(hist_dir);
|
|
|
|
const result = loadSnapshotSide(std.testing.io, testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15));
|
|
try testing.expectError(error.FileNotFound, result);
|
|
}
|
|
|
|
test "aggregateLiveStocks: sums shares for same symbol across accounts" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2026, 5, 8);
|
|
const lots = [_]zfin.Lot{
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Roth" },
|
|
.{ .symbol = "AAPL", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 160, .account = "IRA" },
|
|
.{ .symbol = "MSFT", .shares = 25, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 350, .account = "Roth" },
|
|
};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("AAPL", 200.0);
|
|
try prices.put("MSFT", 400.0);
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
|
|
try testing.expectEqual(@as(u32, 2), map.count());
|
|
const aapl = map.get("AAPL") orelse return error.TestUnexpectedResult;
|
|
try testing.expectApproxEqAbs(@as(f64, 150), aapl.shares, 0.01);
|
|
try testing.expectApproxEqAbs(@as(f64, 200.0), aapl.price, 0.01);
|
|
const msft = map.get("MSFT") orelse return error.TestUnexpectedResult;
|
|
try testing.expectApproxEqAbs(@as(f64, 25), msft.shares, 0.01);
|
|
}
|
|
|
|
test "aggregateLiveStocks: filters out non-stock lots" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2026, 5, 8);
|
|
const lots = [_]zfin.Lot{
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
|
|
.{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash },
|
|
.{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .security_type = .cd, .maturity_date = Date.fromYmd(2027, 1, 1) },
|
|
};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("AAPL", 200.0);
|
|
try prices.put("CASH", 1.0);
|
|
try prices.put("VTI", 250.0);
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
|
|
// Only stock lots make it in.
|
|
try testing.expectEqual(@as(u32, 1), map.count());
|
|
try testing.expect(map.get("AAPL") != null);
|
|
try testing.expect(map.get("CASH") == null);
|
|
try testing.expect(map.get("VTI") == null);
|
|
}
|
|
|
|
test "aggregateLiveStocks: excludes lots not yet open as of today" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2024, 6, 1);
|
|
const lots = [_]zfin.Lot{
|
|
// Already open
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
|
|
// Not yet bought (open_date is in the future)
|
|
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 300 },
|
|
// Sold before today
|
|
.{ .symbol = "GOOG", .shares = 25, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 3, 1), .close_price = 150 },
|
|
};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("AAPL", 200.0);
|
|
try prices.put("MSFT", 400.0);
|
|
try prices.put("GOOG", 175.0);
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
|
|
try testing.expectEqual(@as(u32, 1), map.count());
|
|
try testing.expect(map.get("AAPL") != null);
|
|
}
|
|
|
|
test "aggregateLiveStocks: skips lots with no price in map" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2026, 5, 8);
|
|
const lots = [_]zfin.Lot{
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
|
|
.{ .symbol = "OBSCURE", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 },
|
|
};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
// Only AAPL has a price; OBSCURE does not.
|
|
try prices.put("AAPL", 200.0);
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
|
|
try testing.expectEqual(@as(u32, 1), map.count());
|
|
try testing.expect(map.get("AAPL") != null);
|
|
try testing.expect(map.get("OBSCURE") == null);
|
|
}
|
|
|
|
test "aggregateLiveStocks: applies price_ratio via effectivePrice" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2026, 5, 8);
|
|
// CUSIP-style lot with price_ratio: raw price * ratio = effective.
|
|
const lots = [_]zfin.Lot{
|
|
.{
|
|
.symbol = "02315N600",
|
|
.shares = 100,
|
|
.open_date = Date.fromYmd(2024, 1, 1),
|
|
.open_price = 140,
|
|
.ticker = "VTTHX",
|
|
.price_ratio = 5.0,
|
|
},
|
|
};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTTHX", 30.0); // raw price
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
|
|
// priceSymbol() returns "VTTHX" (the ticker), not the CUSIP.
|
|
const h = map.get("VTTHX") orelse return error.TestUnexpectedResult;
|
|
try testing.expectApproxEqAbs(@as(f64, 100), h.shares, 0.01);
|
|
// effective price = raw * price_ratio = 30 * 5 = 150
|
|
try testing.expectApproxEqAbs(@as(f64, 150.0), h.price, 0.01);
|
|
}
|
|
|
|
test "aggregateLiveStocks: empty portfolio yields empty map" {
|
|
var map: view.HoldingMap = .init(testing.allocator);
|
|
defer map.deinit();
|
|
|
|
const today = Date.fromYmd(2026, 5, 8);
|
|
const lots = [_]zfin.Lot{};
|
|
const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };
|
|
|
|
var prices: std.StringHashMap(f64) = .init(testing.allocator);
|
|
defer prices.deinit();
|
|
|
|
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
|
try testing.expectEqual(@as(u32, 0), map.count());
|
|
}
|