zfin/src/analytics/exposure.zig

428 lines
19 KiB
Zig

//! 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"));
}