428 lines
19 KiB
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"));
|
|
}
|