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