391 lines
16 KiB
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);
|
|
}
|