diff --git a/src/analytics/exposure.zig b/src/analytics/exposure.zig new file mode 100644 index 0000000..b883ba2 --- /dev/null +++ b/src/analytics/exposure.zig @@ -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 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")); +} diff --git a/src/commands/exposure.zig b/src/commands/exposure.zig new file mode 100644 index 0000000..a1f1b57 --- /dev/null +++ b/src/commands/exposure.zig @@ -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 + \\ + \\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); +} diff --git a/src/main.zig b/src/main.zig index af08355..943477a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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"), diff --git a/src/service.zig b/src/service.zig index 8c44f77..78a08f3 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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;