//! `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( allocator: std.mem.Allocator, hist_dir: []const u8, date: Date, ) !SnapshotSide { var loaded = try history.loadSnapshotAt(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( portfolio: *const zfin.Portfolio, prices: *const std.StringHashMap(f64), out_map: *view.HoldingMap, ) !void { const today = fmt.todayDate(); for (portfolio.lots) |lot| { if (lot.security_type != .stock) continue; if (!lot.lotIsOpenAsOf(today)) 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" { 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(.{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes }); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); var side = try loadSnapshotSide(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" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); const result = loadSnapshotSide(testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expectError(error.FileNotFound, result); }