zfin/src/compare.zig

267 lines
11 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(
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);
}