//! Look-through exposure aggregation. //! //! Answers "how much of underlying symbol X do I really hold?" by //! unifying two sources: //! //! 1. **Direct** - a position whose ticker *is* X. //! 2. **Look-through** - X held inside the top holdings of ETFs the //! portfolio owns. A fund worth $V that holds X at weight w //! contributes `V * w` dollars of X exposure. //! //! `analyze` owns the whole transform: it resolves each holding's //! underlying ticker (NPORT ticker, else CUSIP via a caller-supplied //! map), flags fund-of-funds blind spots, and aggregates. It is pure - //! no I/O, no `DataService`. The command fetches ETF profiles and the //! CUSIP map (the I/O) and hands the raw data here, which keeps this //! load-bearing logic unit-testable with literal fixtures. const std = @import("std"); const Holding = @import("../models/etf_profile.zig").Holding; /// A portfolio fund whose holdings are looked through, only when it is /// only a fund - broad equity ETFs with a small cash-sweep "Fund" /// holding don't count. pub const nested_fof_threshold: f64 = 0.20; // 20% /// A directly-held portfolio position, reduced to what the calc needs. pub const DirectPosition = struct { symbol: []const u8, /// Market value of the position. value: f64, }; /// A fund held in the portfolio plus its (unresolved) NPORT-P top /// holdings. `analyze` resolves each holding's underlying ticker. pub const FundInput = struct { /// The fund's own ticker (the symbol held directly). fund: []const u8, /// Market value of the fund position in the portfolio. value: f64, holdings: []const Holding, }; /// One fund's contribution to the target's look-through exposure. pub const FundContribution = struct { fund: []const u8, /// Dollars of the target reached through this fund /// (`fund.value * Σ matching holding weights`). value: f64, /// The target's combined weight *within this fund* (decimal). Two /// share classes of the same underlying in one fund sum here. weight_in_fund: f64, }; /// Aggregated exposure to a single underlying symbol. String fields /// borrow from the `analyze` inputs (which must outlive the result); /// `contributions` and `fund_of_funds` are allocated. pub const ExposureResult = struct { symbol: []const u8, /// Portfolio total value - the denominator for every weight. total_value: f64, /// Dollars of the target held directly. direct_value: f64, /// Dollars of the target reached via ETF look-through. lookthrough_value: f64, /// Per-fund contributions, sorted descending by value. Only funds /// with nonzero exposure to the target appear. contributions: []const FundContribution, /// Count of fund holdings that could not be identified (no ticker, /// no resolvable CUSIP) across all scanned funds. Bounds how much /// exposure the look-through might be undercounting. unresolved_holdings: usize, /// Total market value sitting in funds-of-funds whose underlying /// funds the single-level look-through does not expand (e.g. /// target-date funds holding a total-market index fund). nested_fund_value: f64 = 0, /// Tickers of those funds-of-funds (e.g. `{ "FUNDA", "FUNDB" }`); /// empty when none detected. Names borrow from the inputs; the /// outer slice is allocated. fund_of_funds: []const []const u8 = &.{}, /// Total dollars of the target (direct + look-through). pub fn totalValue(self: ExposureResult) f64 { return self.direct_value + self.lookthrough_value; } /// Total exposure as a fraction of the portfolio (0..1). Zero when /// the portfolio has no value. pub fn totalWeight(self: ExposureResult) f64 { return self.fractionOf(self.totalValue()); } pub fn directWeight(self: ExposureResult) f64 { return self.fractionOf(self.direct_value); } pub fn lookthroughWeight(self: ExposureResult) f64 { return self.fractionOf(self.lookthrough_value); } /// `value` as a fraction of the portfolio total. Safe when total /// is zero (returns 0 rather than dividing). pub fn fractionOf(self: ExposureResult, value: f64) f64 { if (self.total_value <= 0) return 0; return value / self.total_value; } pub fn deinit(self: *ExposureResult, allocator: std.mem.Allocator) void { allocator.free(self.contributions); if (self.fund_of_funds.len > 0) allocator.free(self.fund_of_funds); } }; /// Resolve, flag, and aggregate exposure to `target` from directly-held /// positions plus the look-through holdings of `funds`. /// /// Each holding's underlying ticker is `holding.symbol` (the NPORT-P /// ticker, rarely present) or, failing that, `cusip_to_ticker` applied /// to `holding.cusip`. Holdings that resolve to neither are counted in /// `unresolved_holdings`. Holdings that are themselves funds are flagged /// as a look-through blind spot (`nested_fund_value` / `fund_of_funds`) /// rather than expanded - single level only. /// /// `target` matching is exact and case-sensitive - the caller uppercases /// both the query and (where applicable) the resolved tickers. /// /// `contributions` (sorted descending by dollar value) and /// `fund_of_funds` are allocated from `allocator`; all string fields /// borrow from `directs`, `funds`, and `cusip_to_ticker`. The caller /// owns the result (`deinit`). pub fn analyze( allocator: std.mem.Allocator, target: []const u8, total_value: f64, directs: []const DirectPosition, funds: []const FundInput, cusip_to_ticker: *const std.StringHashMap([]const u8), ) !ExposureResult { var direct_value: f64 = 0; for (directs) |d| { if (std.mem.eql(u8, d.symbol, target)) direct_value += d.value; } var contribs: std.ArrayList(FundContribution) = .empty; errdefer contribs.deinit(allocator); var fof_names: std.ArrayList([]const u8) = .empty; errdefer fof_names.deinit(allocator); var lookthrough_value: f64 = 0; var unresolved: usize = 0; var nested_value: f64 = 0; for (funds) |f| { var fund_value: f64 = 0; var fund_weight: f64 = 0; var fund_nested_value: f64 = 0; var fund_nested_weight: f64 = 0; for (f.holdings) |h| { // Fund-of-funds blind spot: a holding that is itself a fund // is not expanded (no recursion). Tracked for a footnote. if (isNestedFund(h.name)) { fund_nested_value += f.value * h.weight; fund_nested_weight += h.weight; } // Resolve the holding's underlying ticker: the NPORT-P // ticker if present, else the CUSIP via the resolution map. const ticker = h.symbol orelse if (h.cusip) |c| cusip_to_ticker.get(c) else null; if (ticker) |t| { if (std.mem.eql(u8, t, target)) { fund_value += f.value * h.weight; fund_weight += h.weight; } } else { unresolved += 1; } } if (fund_value > 0) { try contribs.append(allocator, .{ .fund = f.fund, .value = fund_value, .weight_in_fund = fund_weight, }); lookthrough_value += fund_value; } // Only a substantial nested-fund share marks a fund-of-funds; a // broad ETF with a small cash-sweep holding does not qualify. if (fund_nested_weight >= nested_fof_threshold) { nested_value += fund_nested_value; try fof_names.append(allocator, f.fund); } } const slice = try contribs.toOwnedSlice(allocator); errdefer allocator.free(slice); std.sort.pdq(FundContribution, slice, {}, struct { fn lessThan(_: void, a: FundContribution, b: FundContribution) bool { return a.value > b.value; // descending } }.lessThan); const fof: []const []const u8 = if (fof_names.items.len > 0) try fof_names.toOwnedSlice(allocator) else blk: { fof_names.deinit(allocator); break :blk &.{}; }; return .{ .symbol = target, .total_value = total_value, .direct_value = direct_value, .lookthrough_value = lookthrough_value, .contributions = slice, .unresolved_holdings = unresolved, .nested_fund_value = nested_value, .fund_of_funds = fof, }; } /// Heuristic: does this holding name denote a fund (ETF or mutual /// fund) rather than an operating company? Used to flag fund-of-funds /// holdings the single-level look-through doesn't expand - e.g. a /// target-date fund's underlying total-market index fund. /// /// Cash-sweep / money-market / central vehicles match "fund" by name /// but are not equity look-through blind spots, so they're excluded. pub fn isNestedFund(name: []const u8) bool { const cash_markers = [_][]const u8{ "money market", "liquid", "prime fund", "sweep", "cash", "government fund", "central fund", }; for (cash_markers) |m| { if (std.ascii.indexOfIgnoreCase(name, m) != null) return false; } return std.ascii.indexOfIgnoreCase(name, "fund") != null or std.ascii.indexOfIgnoreCase(name, " etf") != null; } // ── Tests ──────────────────────────────────────────────────── /// An empty CUSIP->ticker map for tests that resolve purely by NPORT /// ticker. Caller deinits. fn emptyMap() std.StringHashMap([]const u8) { return std.StringHashMap([]const u8).init(std.testing.allocator); } test "analyze: direct only" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const directs = [_]DirectPosition{ .{ .symbol = "AAPL", .value = 10_000 }, .{ .symbol = "MSFT", .value = 5_000 }, }; var result = try analyze(allocator, "AAPL", 100_000, &directs, &.{}, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 10_000), result.direct_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), result.lookthrough_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.totalWeight(), 0.0001); try std.testing.expectEqual(@as(usize, 0), result.contributions.len); } test "analyze: look-through via a single fund (NPORT ticker)" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); // QQQ worth $30,000, holds AAPL at 10%. const holdings = [_]Holding{ .{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.10 }, .{ .name = "Microsoft Corp", .symbol = "MSFT", .weight = 0.08 }, }; const funds = [_]FundInput{.{ .fund = "QQQ", .value = 30_000, .holdings = &holdings }}; var result = try analyze(allocator, "AAPL", 100_000, &.{}, &funds, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 3_000), result.lookthrough_value, 0.01); // 30000 * 0.10 try std.testing.expectEqual(@as(usize, 1), result.contributions.len); try std.testing.expectEqualStrings("QQQ", result.contributions[0].fund); try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.contributions[0].weight_in_fund, 0.0001); } test "analyze: resolves a null-ticker holding via the CUSIP map" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); try map.put("111111111", "AMZN"); // SPY-style holding: no NPORT ticker, CUSIP only. const holdings = [_]Holding{ .{ .name = "Amazon.com Inc", .cusip = "111111111", .weight = 0.05 }, }; const funds = [_]FundInput{.{ .fund = "SPY", .value = 1_000_000, .holdings = &holdings }}; var result = try analyze(allocator, "AMZN", 5_000_000, &.{}, &funds, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.lookthrough_value, 0.01); // 1,000,000 * 0.05 try std.testing.expectEqual(@as(usize, 1), result.contributions.len); try std.testing.expectEqualStrings("SPY", result.contributions[0].fund); try std.testing.expectEqual(@as(usize, 0), result.unresolved_holdings); } test "analyze: unifies direct + indirect for the same symbol" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const directs = [_]DirectPosition{.{ .symbol = "AAPL", .value = 12_500 }}; const qqq = [_]Holding{.{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.10 }}; const xlk = [_]Holding{.{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.20 }}; const funds = [_]FundInput{ .{ .fund = "QQQ", .value = 30_000, .holdings = &qqq }, // 3,000 .{ .fund = "XLK", .value = 5_000, .holdings = &xlk }, // 1,000 }; var result = try analyze(allocator, "AAPL", 100_000, &directs, &funds, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 12_500), result.direct_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 4_000), result.lookthrough_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.165), result.totalWeight(), 0.0001); // Sorted descending: QQQ (3,000) before XLK (1,000). try std.testing.expectEqual(@as(usize, 2), result.contributions.len); try std.testing.expectEqualStrings("QQQ", result.contributions[0].fund); try std.testing.expectEqualStrings("XLK", result.contributions[1].fund); } test "analyze: unresolvable holdings are skipped and counted" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const holdings = [_]Holding{ .{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.05 }, .{ .name = "Some Foreign Bond", .weight = 0.30 }, // no symbol, no cusip .{ .name = "Another Foreign Name", .cusip = "999999999", .weight = 0.20 }, // cusip not in map }; const funds = [_]FundInput{.{ .fund = "AGG", .value = 10_000, .holdings = &holdings }}; var result = try analyze(allocator, "AAPL", 100_000, &.{}, &funds, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 500), result.lookthrough_value, 0.01); // 10000 * 0.05 try std.testing.expectEqual(@as(usize, 2), result.unresolved_holdings); } test "analyze: no exposure yields empty result" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const directs = [_]DirectPosition{.{ .symbol = "MSFT", .value = 5_000 }}; const holdings = [_]Holding{.{ .name = "Nvidia Corp", .symbol = "NVDA", .weight = 0.40 }}; const funds = [_]FundInput{.{ .fund = "SMH", .value = 8_000, .holdings = &holdings }}; var result = try analyze(allocator, "AAPL", 100_000, &directs, &funds, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 0), result.totalValue(), 0.01); try std.testing.expectEqual(@as(usize, 0), result.contributions.len); } test "analyze: two share classes of the target in one fund sum" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const holdings = [_]Holding{ .{ .name = "Alphabet Inc", .symbol = "GOOGL", .weight = 0.03 }, .{ .name = "Alphabet Inc", .symbol = "GOOGL", .weight = 0.02 }, }; const funds = [_]FundInput{.{ .fund = "VOO", .value = 50_000, .holdings = &holdings }}; var result = try analyze(allocator, "GOOGL", 200_000, &.{}, &funds, &map); defer result.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), result.contributions.len); try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.contributions[0].weight_in_fund, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 2_500), result.lookthrough_value, 0.01); // 50000 * 0.05 } test "analyze: zero total_value does not divide by zero" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); const directs = [_]DirectPosition{.{ .symbol = "AAPL", .value = 0 }}; var result = try analyze(allocator, "AAPL", 0, &directs, &.{}, &map); defer result.deinit(allocator); try std.testing.expectApproxEqAbs(@as(f64, 0), result.totalWeight(), 0.0001); } test "analyze: flags a fund-of-funds, not a broad ETF with cash" { const allocator = std.testing.allocator; var map = emptyMap(); defer map.deinit(); // Target-date-style wrapper: ~99% in underlying index funds. const wrapper = [_]Holding{ .{ .name = "Sample Total Stock Market Index Fund", .weight = 0.40 }, .{ .name = "Sample Total Bond Market Index Fund", .weight = 0.35 }, .{ .name = "Sample Total International Index Fund", .weight = 0.24 }, .{ .name = "Sample Market Liquidity Fund", .weight = 0.01 }, // cash, excluded }; // Broad fund with one small cash-sweep "Fund" - NOT a fund-of-funds. const broad = [_]Holding{ .{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 }, .{ .name = "Sample Private Government Fund", .weight = 0.08 }, // cash, excluded }; const funds = [_]FundInput{ .{ .fund = "FUNDA", .value = 100_000, .holdings = &wrapper }, .{ .fund = "FUNDB", .value = 50_000, .holdings = &broad }, }; var result = try analyze(allocator, "AAPL", 1_000_000, &.{}, &funds, &map); defer result.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), result.fund_of_funds.len); try std.testing.expectEqualStrings("FUNDA", result.fund_of_funds[0]); // nested value = 100k * (0.40 + 0.35 + 0.24) = 99k (liquidity excluded). try std.testing.expectApproxEqAbs(@as(f64, 99_000), result.nested_fund_value, 1.0); } test "isNestedFund: flags index/ETF funds, skips operating cos and cash" { // Nested funds (the look-through blind spot). try std.testing.expect(isNestedFund("Sample Total Stock Market Index Fund")); try std.testing.expect(isNestedFund("Sample Total Bond Market Index Fund")); try std.testing.expect(isNestedFund("Sample Core S&P 500 ETF")); // Operating companies are not funds. try std.testing.expect(!isNestedFund("Sample Operating Co")); try std.testing.expect(!isNestedFund("Another Operating Co Inc")); // Cash-sweep / money-market / central vehicles are funds by name // but excluded - one case per marker. try std.testing.expect(!isNestedFund("Sample Market Liquidity Fund")); try std.testing.expect(!isNestedFund("Sample Private Prime Fund")); try std.testing.expect(!isNestedFund("Sample Private Government Fund")); try std.testing.expect(!isNestedFund("Sample Private Credit Central Fund LLC")); try std.testing.expect(!isNestedFund("Sample Liquid Assets Portfolio")); try std.testing.expect(!isNestedFund("Sample Money Market Fund")); }