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); } test "display: warning band (5-8%) renders the total line" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); // 6% total -> in [warn_threshold, flag_threshold) -> WARNING color. const result: exposure.ExposureResult = .{ .symbol = "AAPL", .total_value = 100_000, .direct_value = 6_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, "Total exposure") != null); try std.testing.expect(std.mem.indexOf(u8, o, "$6,000") != null); try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null); } test "display: accent band (<5%) renders the total line" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); // 3% total -> below warn_threshold -> ACCENT color. const result: exposure.ExposureResult = .{ .symbol = "AAPL", .total_value = 100_000, .direct_value = 3_000, .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, "Total exposure") != null); try std.testing.expect(std.mem.indexOf(u8, o, "$3,000") != null); }