zfin/src/commands/exposure.zig

391 lines
16 KiB
Zig

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);
}
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);
}