add exposure command
All checks were successful
Generic zig build / build (push) Successful in 5m58s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 27s

This commit is contained in:
Emil Lerch 2026-06-17 12:50:42 -07:00
parent beb8ed156b
commit 1c4f85f8da
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 819 additions and 5 deletions

428
src/analytics/exposure.zig Normal file
View file

@ -0,0 +1,428 @@
//! 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 CUSIPticker 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"));
}

354
src/commands/exposure.zig Normal file
View file

@ -0,0 +1,354 @@
const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const exposure = @import("../analytics/exposure.zig");
const Holding = @import("../models/etf_profile.zig").Holding;
/// Warn / flag thresholds for total single-name exposure, expressed as
/// a fraction of the portfolio. Shared conceptually with the planned
/// look-through concentration check (see CONCENTRATION_CHECK_PLAN.md);
/// here they only drive the color of the total line.
const warn_threshold: f64 = 0.05; // 5%
const flag_threshold: f64 = 0.08; // 8%
pub const ParsedArgs = struct {
symbol: []const u8,
};
pub const meta: framework.Meta = .{
.name = "exposure",
.group = .portfolio,
.synopsis = "Show true exposure to a symbol (direct + look-through via ETFs)",
.uppercase_first_arg = true,
.help =
\\Usage: zfin exposure <SYMBOL>
\\
\\Show how much of a single underlying symbol you really hold —
\\directly, plus look-through via the top holdings of every ETF
\\in the portfolio. A fund worth $V that holds SYMBOL at weight w
\\contributes V*w of exposure.
\\
\\ETF holdings are matched to SYMBOL by ticker when the NPORT-P
\\filing carries one, otherwise by resolving the holding's CUSIP
\\to a ticker (local cache -> ZFIN_SERVER -> OpenFIGI). Holdings
\\with no ticker and no resolvable CUSIP (bonds, derivatives) are
\\excluded.
\\
\\ETF profiles come from SEC EDGAR and are cached ~90 days. The
\\first run on a cold cache fetches them and can take ~15s.
\\
\\Examples:
\\ zfin exposure AAPL # how much Apple do I really own?
\\ zfin exposure NVDA
\\
,
.user_errors = error{ MissingSymbol, UnexpectedArg },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
cli.stderrPrint(ctx.io, "Error: 'exposure' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
cli.stderrPrint(ctx.io, "Error: 'exposure' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator; // per-invocation arena
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const target = parsed.symbol; // already uppercased by the framework
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
// Refresh prices so position market values are TTL-fresh (mirrors
// the `analysis` command). The loader prints its own stderr summary.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
if (syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| {
log.warn("exposure: price map put({s}): {t}", .{ entry.key_ptr.*, err });
};
}
}
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
};
defer pf_data.deinit(allocator);
const allocations = pf_data.summary.allocations;
const total_value = pf_data.summary.total_value;
if (allocations.len > 0) {
cli.stderrPrint(io, "Scanning portfolio for look-through exposure (uncached ETFs fetch from EDGAR; first run can take ~15s)...\n");
}
// Fetch each portfolio fund's NPORT-P holdings. `getEtfProfile`
// returns NotFound for non-ETF symbols (and errors on fetch
// failure); both are skipped silently. Holding strings are duped
// into the arena so they survive the per-iteration
// `FetchResult.deinit`; the CUSIPs we'll need resolved are gathered
// as we go. The actual resolution + aggregation is `exposure.analyze`.
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
var funds: std.ArrayList(exposure.FundInput) = .empty;
var cusips: std.ArrayList([]const u8) = .empty;
for (allocations) |alloc| {
const res = svc.getEtfProfile(alloc.symbol, opts) catch continue;
defer res.deinit();
if (!res.data.isEtf()) continue;
const holdings = res.data.holdings orelse continue;
var hs: std.ArrayList(Holding) = .empty;
for (holdings) |h| {
const sym: ?[]const u8 = if (h.symbol) |s| try allocator.dupe(u8, s) else null;
const cus: ?[]const u8 = if (h.cusip) |c| try allocator.dupe(u8, c) else null;
try hs.append(allocator, .{
.name = try allocator.dupe(u8, h.name),
.symbol = sym,
.cusip = cus,
.weight = h.weight,
});
// Only null-ticker holdings need CUSIP->ticker resolution.
if (sym == null) {
if (cus) |c| try cusips.append(allocator, c);
}
}
// `alloc.symbol` lives in `pf_data` (released after display), so
// borrow it rather than duping.
try funds.append(allocator, .{
.fund = alloc.symbol,
.value = alloc.market_value,
.holdings = try hs.toOwnedSlice(allocator),
});
}
// Resolve the union of unresolved CUSIPs in one batched cascade.
// Offline mode (`--refresh-data=never`) restricts resolution to the
// local L1 cache via `skip_network`, so cached CUSIPs still resolve
// but nothing hits the server or OpenFIGI.
const offline = ctx.globals.refresh_policy == .never;
var cusip_map = svc.resolveCusips(allocator, cusips.items, offline);
defer cusip_map.deinit();
var directs: std.ArrayList(exposure.DirectPosition) = .empty;
for (allocations) |alloc| {
try directs.append(allocator, .{ .symbol = alloc.symbol, .value = alloc.market_value });
}
var result = try exposure.analyze(allocator, target, total_value, directs.items, funds.items, &cusip_map.map);
defer result.deinit(allocator);
var label_buf: [512]u8 = undefined;
const anchor_path = loaded.anchor();
const label: []const u8 = if (loaded.paths.len > 1)
std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
else
anchor_path;
try display(result, label, color, out);
}
const log = std.log.scoped(.exposure);
/// Render an exposure result. Pulled out of `run` so it can be tested
/// without a portfolio, network, or DataService.
pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nExposure to {s} ({s})\n", .{ result.symbol, label });
try out.print("========================================\n\n", .{});
if (result.totalValue() <= 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " No exposure found — {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol});
return;
}
const total_w = result.totalWeight();
const total_color = if (total_w >= flag_threshold)
cli.CLR_NEGATIVE
else if (total_w >= warn_threshold)
cli.CLR_WARNING
else
cli.CLR_ACCENT;
var pbuf: [16]u8 = undefined;
try cli.setBold(out, color);
try cli.printFg(out, color, total_color, " Total exposure {s:>6} {f}\n", .{ fmt.fmtPct(&pbuf, total_w, .{}), Money.from(result.totalValue()).padRight(13) });
try cli.printFg(out, color, cli.CLR_MUTED, " Direct {s:>6} {f}\n", .{ fmt.fmtPct(&pbuf, result.directWeight(), .{}), Money.from(result.direct_value).padRight(13) });
try cli.printFg(out, color, cli.CLR_MUTED, " Look-through {s:>6} {f}\n", .{ fmt.fmtPct(&pbuf, result.lookthroughWeight(), .{}), Money.from(result.lookthrough_value).padRight(13) });
if (result.contributions.len > 0) {
try cli.printBold(out, color, "\n Via funds:\n", .{});
var wbuf: [16]u8 = undefined;
for (result.contributions) |c| {
try cli.printFg(out, color, cli.CLR_ACCENT, " {s:<8}", .{c.fund});
try out.print(" {s:>6} {f} ", .{ fmt.fmtPct(&pbuf, result.fractionOf(c.value), .{}), Money.from(c.value).padRight(13) });
try cli.printFg(out, color, cli.CLR_MUTED, "({s} is {s} of {s})\n", .{ result.symbol, fmt.fmtPct(&wbuf, c.weight_in_fund, .{}), c.fund });
}
}
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Based on each ETF's latest NPORT-P top holdings, matched by CUSIP.\n", .{});
if (result.unresolved_holdings > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " {d} holding(s) without a resolvable US identifier — typically\n", .{result.unresolved_holdings});
try cli.printFg(out, color, cli.CLR_MUTED, " foreign-listed securities and cash — are outside look-through.\n\n", .{});
}
if (result.fund_of_funds.len > 0) {
var nbuf: [16]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, " Funds-of-funds not expanded (", .{});
for (result.fund_of_funds, 0..) |name, i| {
try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}", .{ if (i == 0) "" else ", ", name });
}
try cli.printFg(out, color, cli.CLR_MUTED, ") hold ~{f}\n", .{Money.from(result.nested_fund_value).whole()});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} of the portfolio); {s} inside them isn't counted.\n", .{ fmt.fmtPct(&nbuf, result.fractionOf(result.nested_fund_value), .{}), result.symbol });
}
try out.print("\n", .{});
}
// Tests
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"AAPL"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("AAPL", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "display: full breakdown with direct + funds" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const contribs = [_]exposure.FundContribution{
.{ .fund = "QQQ", .value = 20_000, .weight_in_fund = 0.10 },
.{ .fund = "XLK", .value = 5_000, .weight_in_fund = 0.05 },
};
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 10_000,
.lookthrough_value = 25_000,
.contributions = &contribs,
.unresolved_holdings = 1,
};
try display(result, "portfolio.srf", false, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "Exposure to AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "Direct") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "Look-through") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "Via funds:") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "QQQ") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "10.0% of QQQ") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "$10,000") != null);
// unresolved footnote present
try std.testing.expect(std.mem.indexOf(u8, o, "without a resolvable US identifier") != null);
// no color
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") == null);
}
test "display: funds-of-funds footnote when nested funds present" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 5_000,
.lookthrough_value = 10_000,
.contributions = &.{},
.unresolved_holdings = 0,
.nested_fund_value = 20_000,
.fund_of_funds = &.{ "FUNDA", "FUNDB", "FUNDC" },
};
try display(result, "portfolio.srf", false, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "Funds-of-funds not expanded") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "FUNDA, FUNDB, FUNDC") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "$20,000") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "20.0%") != null); // 20k / 100k
}
test "display: no funds-of-funds footnote when none present" {
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 10_000,
.lookthrough_value = 0,
.contributions = &.{},
.unresolved_holdings = 0,
};
try display(result, "portfolio.srf", false, &w);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Funds-of-funds") == null);
}
test "display: no exposure message" {
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: exposure.ExposureResult = .{
.symbol = "TSLA",
.total_value = 100_000,
.direct_value = 0,
.lookthrough_value = 0,
.contributions = &.{},
.unresolved_holdings = 0,
};
try display(result, "portfolio.srf", false, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "No exposure found") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "TSLA") != null);
// No "Via funds" section when there's nothing.
try std.testing.expect(std.mem.indexOf(u8, o, "Via funds") == null);
}
test "display: high concentration emits color when enabled" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
// 10% total -> above flag_threshold -> colored.
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 10_000,
.lookthrough_value = 0,
.contributions = &.{},
.unresolved_holdings = 0,
};
try display(result, "portfolio.srf", true, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
}

View file

@ -25,6 +25,7 @@ const command_modules = .{
// Portfolio analysis
.portfolio = @import("commands/portfolio.zig"),
.analysis = @import("commands/analysis.zig"),
.exposure = @import("commands/exposure.zig"),
.review = @import("commands/review.zig"),
.projections = @import("commands/projections.zig"),
.milestones = @import("commands/milestones.zig"),

View file

@ -2574,6 +2574,11 @@ pub const DataService = struct {
/// L2 server `GET /cusips` whole-file sync (if ZFIN_SERVER set)
/// L3 OpenFIGI batch lookup (whatever still misses)
///
/// `skip_network = true` restricts resolution to L1 (the local
/// cache) for offline mode (`--refresh-data=never`). L2/L3 and
/// the persist-back are skipped entirely; cached CUSIPs still
/// resolve, uncached ones stay unresolved.
///
/// Best-effort: network failures degrade to "fewer entries
/// resolved" rather than erroring. The returned `CusipTickerMap` is
/// a zero-copy view over the (possibly just-rewritten) local file
@ -2585,12 +2590,13 @@ pub const DataService = struct {
/// Empty/duplicate CUSIPs in `cusips` are ignored. The caller owns
/// the returned map (`deinit`); pass a scratch allocator to scope
/// it to a single command invocation.
pub fn resolveCusips(self: *DataService, allocator: std.mem.Allocator, cusips: []const []const u8) CusipTickerMap {
pub fn resolveCusips(self: *DataService, allocator: std.mem.Allocator, cusips: []const []const u8, skip_network: bool) CusipTickerMap {
var result = self.loadCusipTickerMap(allocator);
// Fast path: everything already in L1 no scratch, no network,
// no rewrite. This is the warm-cache common case.
if (!anyMissing(result, cusips)) return result;
// Offline mode serves only L1. Also the warm-cache fast path:
// when nothing is missing there's no scratch, no network, no
// rewrite.
if (skip_network or !anyMissing(result, cusips)) return result;
// Scratch arena for minted entries; decouples their lifetime
// from the server body / OpenFIGI result buffers freed below.
@ -4274,12 +4280,37 @@ test "resolveCusips: warm cache resolves without touching the network" {
// Duplicate + empty CUSIP in the request must be tolerated.
const want = [_][]const u8{ "111111111", "222222222", "111111111", "" };
var map = svc.resolveCusips(allocator, want[0..]);
var map = svc.resolveCusips(allocator, want[0..], false);
defer map.deinit();
try std.testing.expectEqualStrings("AAA", map.get("111111111").?);
try std.testing.expectEqualStrings("BBB", map.get("222222222").?);
}
test "resolveCusips: skip_network serves L1 only, never hits the network" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path });
defer svc.deinit();
// A miss would normally fall through to L2/L3; skip_network must
// prevent any network attempt even so.
svc.panic_on_network_attempt = true;
svc.cacheCusipTicker("111111111", "AAA");
// "999999999" is absent from L1 with skip_network it stays
// unresolved rather than triggering a server/OpenFIGI lookup.
const want = [_][]const u8{ "111111111", "999999999" };
var map = svc.resolveCusips(allocator, want[0..], true);
defer map.deinit();
try std.testing.expectEqualStrings("AAA", map.get("111111111").?);
try std.testing.expect(map.get("999999999") == null);
}
test "getEtfProfile: carries holding CUSIP through the model boundary" {
const allocator = std.testing.allocator;
const io = std.testing.io;