Compare commits
5 commits
d9fd5d5b97
...
1c4f85f8da
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c4f85f8da | |||
| beb8ed156b | |||
| 78ffecba4f | |||
| 867f9afb8c | |||
| 415071b955 |
9 changed files with 1432 additions and 163 deletions
|
|
@ -1,5 +1,10 @@
|
|||
[tools]
|
||||
prek = "0.4.1"
|
||||
zig = "0.16.0"
|
||||
zls = "0.16.0"
|
||||
"ubi:DonIsaac/zlint" = "0.8.1"
|
||||
|
||||
[tools."github:j178/prek"]
|
||||
version = "0.4.1"
|
||||
|
||||
[tools."github:j178/prek".platforms]
|
||||
linux-riscv64 = { asset_pattern = "prek-riscv64gc-unknown-linux-gnu.tar.gz" }
|
||||
|
|
|
|||
428
src/analytics/exposure.zig
Normal file
428
src/analytics/exposure.zig
Normal 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 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"));
|
||||
}
|
||||
139
src/cache/store.zig
vendored
139
src/cache/store.zig
vendored
|
|
@ -9,9 +9,6 @@ const Dividend = @import("../models/dividend.zig").Dividend;
|
|||
const DividendType = @import("../models/dividend.zig").DividendType;
|
||||
const Split = @import("../models/split.zig").Split;
|
||||
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
|
||||
const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
|
||||
const Holding = @import("../models/etf_profile.zig").Holding;
|
||||
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight;
|
||||
const Wikidata = @import("../providers/Wikidata.zig");
|
||||
const Edgar = @import("../providers/Edgar.zig");
|
||||
|
||||
|
|
@ -45,8 +42,6 @@ pub const Ttl = struct {
|
|||
pub const options: i64 = std.time.s_per_hour;
|
||||
/// Earnings refresh monthly, with smart refresh after announcements
|
||||
pub const earnings: i64 = 30 * s_per_day;
|
||||
/// ETF profiles refresh monthly
|
||||
pub const etf_profile: i64 = 30 * s_per_day;
|
||||
|
||||
/// Per-symbol classification record (sector / industry / country /
|
||||
/// inception_date / CIK) sourced from Wikidata. The data changes
|
||||
|
|
@ -171,7 +166,6 @@ pub const DataType = enum {
|
|||
splits,
|
||||
options,
|
||||
earnings,
|
||||
etf_profile,
|
||||
meta,
|
||||
/// Per-symbol classification record sourced from Wikidata.
|
||||
/// Stored at `<cache_dir>/<symbol>/classification.srf`.
|
||||
|
|
@ -204,7 +198,6 @@ pub const DataType = enum {
|
|||
.splits => "splits.srf",
|
||||
.options => "options.srf",
|
||||
.earnings => "earnings.srf",
|
||||
.etf_profile => "etf_profile.srf",
|
||||
.meta => "meta.srf",
|
||||
.classification => "classification.srf",
|
||||
.etf_metrics => "etf_metrics.srf",
|
||||
|
|
@ -231,7 +224,7 @@ pub const DataType = enum {
|
|||
/// because the absolute spread on a 30d/90d base is
|
||||
/// already large in days.
|
||||
///
|
||||
/// - 0% on the rest. options/earnings/etf_profile either
|
||||
/// - 0% on the rest. options/earnings either
|
||||
/// have natural cadence spread or are short-TTL enough
|
||||
/// that jitter would exceed meaningful drift.
|
||||
pub fn ttl(self: DataType) TtlSpec {
|
||||
|
|
@ -240,7 +233,6 @@ pub const DataType = enum {
|
|||
.splits => .{ .seconds = Ttl.splits, .jitter_pct = 11 },
|
||||
.options => .{ .seconds = Ttl.options },
|
||||
.earnings => .{ .seconds = Ttl.earnings },
|
||||
.etf_profile => .{ .seconds = Ttl.etf_profile },
|
||||
.classification => .{ .seconds = Ttl.classification, .jitter_pct = 8 },
|
||||
.etf_metrics => .{ .seconds = Ttl.etf_metrics, .jitter_pct = 8 },
|
||||
.entity_facts => .{ .seconds = Ttl.entity_facts, .jitter_pct = 8 },
|
||||
|
|
@ -292,7 +284,6 @@ pub const Store = struct {
|
|||
Split => .splits,
|
||||
EarningsEvent => .earnings,
|
||||
OptionsChain => .options,
|
||||
EtfProfile => .etf_profile,
|
||||
Wikidata.ClassificationRecord => .classification,
|
||||
Edgar.EtfMetricRecord => .etf_metrics,
|
||||
Edgar.EntityFactRecord => .entity_facts,
|
||||
|
|
@ -302,9 +293,10 @@ pub const Store = struct {
|
|||
};
|
||||
}
|
||||
|
||||
/// The data payload for a given type: single struct for EtfProfile, slice for everything else.
|
||||
/// The data payload for a given type. Every supported type is
|
||||
/// cached as a slice of records.
|
||||
pub fn DataFor(comptime T: type) type {
|
||||
return if (T == EtfProfile) EtfProfile else []T;
|
||||
return []T;
|
||||
}
|
||||
|
||||
pub fn CacheResult(comptime T: type) type {
|
||||
|
|
@ -345,15 +337,12 @@ pub const Store = struct {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (T == EtfProfile or T == OptionsChain) {
|
||||
if (T == OptionsChain) {
|
||||
const is_negative = std.mem.eql(u8, data, negative_cache_content);
|
||||
if (is_negative) {
|
||||
if (freshness == .fresh_only) {
|
||||
// Negative entries are always fresh — return empty data
|
||||
if (T == EtfProfile)
|
||||
return .{ .data = EtfProfile{ .symbol = "" }, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
|
||||
if (T == OptionsChain)
|
||||
return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
|
||||
return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -368,21 +357,14 @@ pub const Store = struct {
|
|||
}
|
||||
const timestamp = it.created orelse std.Io.Timestamp.now(self.io, .real).toSeconds();
|
||||
|
||||
if (T == EtfProfile) {
|
||||
const profile = deserializeEtfProfile(allocator, &it) catch return null;
|
||||
return .{ .data = profile, .timestamp = timestamp };
|
||||
}
|
||||
if (T == OptionsChain) {
|
||||
const items = deserializeOptions(allocator, &it) catch return null;
|
||||
return .{ .data = items, .timestamp = timestamp };
|
||||
}
|
||||
const items = deserializeOptions(allocator, &it) catch return null;
|
||||
return .{ .data = items, .timestamp = timestamp };
|
||||
}
|
||||
|
||||
return readSlice(T, self.io, allocator, data, postProcess, freshness);
|
||||
}
|
||||
|
||||
/// Serialize data and write to cache with the given TTL.
|
||||
/// Accepts a slice for most types, or a single struct for EtfProfile.
|
||||
///
|
||||
/// For `Dividend` and `Split`, this dispatches to `writeMerged`,
|
||||
/// which performs sorted-union-with-existing semantics rather than
|
||||
|
|
@ -419,17 +401,6 @@ pub const Store = struct {
|
|||
}
|
||||
const expires = computeExpires(std.Io.Timestamp.now(self.io, .real).toSeconds(), ttl, symbol);
|
||||
const data_type = dataTypeFor(T);
|
||||
if (T == EtfProfile) {
|
||||
const srf_data = serializeEtfProfile(self.io, self.allocator, items, .{ .expires = expires }) catch |err| {
|
||||
log.warn("{s}: failed to serialize ETF profile: {s}", .{ symbol, @errorName(err) });
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(srf_data);
|
||||
self.writeRaw(symbol, data_type, srf_data) catch |err| {
|
||||
log.warn("{s}: failed to write ETF profile to cache: {s}", .{ symbol, @errorName(err) });
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (T == OptionsChain) {
|
||||
const srf_data = serializeOptions(self.io, self.allocator, items, .{ .expires = expires }) catch |err| {
|
||||
log.warn("{s}: failed to serialize options: {s}", .{ symbol, @errorName(err) });
|
||||
|
|
@ -1370,10 +1341,6 @@ pub const Store = struct {
|
|||
try std.testing.expect(!hasNoStringFields(EarningsEvent));
|
||||
}
|
||||
|
||||
test "hasNoStringFields: EtfProfile has string fields -> false" {
|
||||
try std.testing.expect(!hasNoStringFields(EtfProfile));
|
||||
}
|
||||
|
||||
test "hasNoStringFields: synthetic shapes" {
|
||||
// Pure ints/floats/bools/enums + Date — should pass.
|
||||
const Pure = struct {
|
||||
|
|
@ -1759,83 +1726,6 @@ pub const Store = struct {
|
|||
|
||||
return chains.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
// ── Private serialization: ETF profile (bespoke) ─────────────
|
||||
|
||||
const EtfRecord = union(enum) {
|
||||
pub const srf_tag_field = "type";
|
||||
meta: EtfProfile,
|
||||
sector: SectorWeight,
|
||||
holding: Holding,
|
||||
};
|
||||
|
||||
fn serializeEtfProfile(io: std.Io, allocator: std.mem.Allocator, profile: EtfProfile, options: srf.FormatOptions) ![]const u8 {
|
||||
var records: std.ArrayList(EtfRecord) = .empty;
|
||||
defer records.deinit(allocator);
|
||||
|
||||
try records.append(allocator, .{ .meta = profile });
|
||||
if (profile.sectors) |sectors| {
|
||||
for (sectors) |s| try records.append(allocator, .{ .sector = s });
|
||||
}
|
||||
if (profile.holdings) |holdings| {
|
||||
for (holdings) |h| try records.append(allocator, .{ .holding = h });
|
||||
}
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
errdefer aw.deinit();
|
||||
var opts = options;
|
||||
opts.created = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
try aw.writer.print("{f}", .{srf.fmt(EtfRecord, records.items, opts)});
|
||||
return aw.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn deserializeEtfProfile(allocator: std.mem.Allocator, it: *srf.RecordIterator) !EtfProfile {
|
||||
var profile = EtfProfile{ .symbol = "" };
|
||||
var sectors: std.ArrayList(SectorWeight) = .empty;
|
||||
errdefer {
|
||||
for (sectors.items) |s| allocator.free(s.name);
|
||||
sectors.deinit(allocator);
|
||||
}
|
||||
var holdings: std.ArrayList(Holding) = .empty;
|
||||
errdefer {
|
||||
for (holdings.items) |h| {
|
||||
if (h.symbol) |s| allocator.free(s);
|
||||
allocator.free(h.name);
|
||||
}
|
||||
holdings.deinit(allocator);
|
||||
}
|
||||
|
||||
while (try it.next()) |fields| {
|
||||
const etf_rec = fields.to(EtfRecord, .{}) catch continue;
|
||||
switch (etf_rec) {
|
||||
.meta => |m| {
|
||||
profile = m;
|
||||
},
|
||||
.sector => |s| {
|
||||
const duped = try allocator.dupe(u8, s.name);
|
||||
try sectors.append(allocator, .{ .name = duped, .weight = s.weight });
|
||||
},
|
||||
.holding => |h| {
|
||||
const duped_sym = if (h.symbol) |s| try allocator.dupe(u8, s) else null;
|
||||
const duped_name = try allocator.dupe(u8, h.name);
|
||||
try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = h.weight });
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (sectors.items.len > 0) {
|
||||
profile.sectors = try sectors.toOwnedSlice(allocator);
|
||||
} else {
|
||||
sectors.deinit(allocator);
|
||||
}
|
||||
if (holdings.items.len > 0) {
|
||||
profile.holdings = try holdings.toOwnedSlice(allocator);
|
||||
} else {
|
||||
holdings.deinit(allocator);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
};
|
||||
|
||||
/// Serialize a portfolio (list of lots) to SRF format.
|
||||
|
|
@ -2736,9 +2626,8 @@ test "TTL constants are reasonable" {
|
|||
// Options refresh hourly
|
||||
try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options);
|
||||
|
||||
// Earnings and ETF profiles refresh monthly
|
||||
// Earnings refresh monthly
|
||||
try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.earnings);
|
||||
try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.etf_profile);
|
||||
|
||||
// New types: classification (90d) and etf_metrics (90d) refresh
|
||||
// quarterly; entity_facts (30d) refreshes monthly.
|
||||
|
|
@ -2781,13 +2670,11 @@ test "DataType.ttl returns correct seconds and jitter policy" {
|
|||
try std.testing.expectEqual(Ttl.tickers_companies, tc.seconds);
|
||||
try std.testing.expectEqual(@as(u8, 8), tc.jitter_pct);
|
||||
|
||||
// No jitter: short-TTL types and etf_profile.
|
||||
// No jitter: short-TTL types.
|
||||
try std.testing.expectEqual(Ttl.options, DataType.options.ttl().seconds);
|
||||
try std.testing.expectEqual(@as(u8, 0), DataType.options.ttl().jitter_pct);
|
||||
try std.testing.expectEqual(Ttl.earnings, DataType.earnings.ttl().seconds);
|
||||
try std.testing.expectEqual(@as(u8, 0), DataType.earnings.ttl().jitter_pct);
|
||||
try std.testing.expectEqual(Ttl.etf_profile, DataType.etf_profile.ttl().seconds);
|
||||
try std.testing.expectEqual(@as(u8, 0), DataType.etf_profile.ttl().jitter_pct);
|
||||
|
||||
// candles_daily, candles_meta, and meta have their own writers
|
||||
// (`cacheCandles`, `writeNegative`); calling .ttl() on them is
|
||||
|
|
@ -2801,7 +2688,6 @@ test "DataType.fileName returns correct file names" {
|
|||
try std.testing.expectEqualStrings("splits.srf", DataType.splits.fileName());
|
||||
try std.testing.expectEqualStrings("options.srf", DataType.options.fileName());
|
||||
try std.testing.expectEqualStrings("earnings.srf", DataType.earnings.fileName());
|
||||
try std.testing.expectEqualStrings("etf_profile.srf", DataType.etf_profile.fileName());
|
||||
try std.testing.expectEqualStrings("meta.srf", DataType.meta.fileName());
|
||||
try std.testing.expectEqualStrings("classification.srf", DataType.classification.fileName());
|
||||
try std.testing.expectEqualStrings("etf_metrics.srf", DataType.etf_metrics.fileName());
|
||||
|
|
@ -3095,13 +2981,10 @@ test "Store.dataTypeFor maps model types correctly" {
|
|||
try std.testing.expectEqual(DataType.splits, Store.dataTypeFor(Split));
|
||||
try std.testing.expectEqual(DataType.earnings, Store.dataTypeFor(EarningsEvent));
|
||||
try std.testing.expectEqual(DataType.options, Store.dataTypeFor(OptionsChain));
|
||||
try std.testing.expectEqual(DataType.etf_profile, Store.dataTypeFor(EtfProfile));
|
||||
}
|
||||
|
||||
test "Store.DataFor returns correct types" {
|
||||
// EtfProfile returns single struct, others return slices
|
||||
try std.testing.expect(@TypeOf(Store.DataFor(EtfProfile)) == type);
|
||||
try std.testing.expect(Store.DataFor(EtfProfile) == EtfProfile);
|
||||
// Every supported type is cached as a slice of records.
|
||||
try std.testing.expect(Store.DataFor(Candle) == []Candle);
|
||||
try std.testing.expect(Store.DataFor(Dividend) == []Dividend);
|
||||
try std.testing.expect(Store.DataFor(Split) == []Split);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ const display_types = [_]DataType{
|
|||
.splits,
|
||||
.options,
|
||||
.earnings,
|
||||
.etf_profile,
|
||||
};
|
||||
|
||||
const display_labels = [_][]const u8{
|
||||
|
|
@ -52,7 +51,6 @@ const display_labels = [_][]const u8{
|
|||
"splits",
|
||||
"options",
|
||||
"earnings",
|
||||
"etf_profile",
|
||||
};
|
||||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
|
|
|
|||
354
src/commands/exposure.zig
Normal file
354
src/commands/exposure.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -53,6 +53,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip});
|
||||
}
|
||||
|
||||
// L1: check the local cache before any network call. Hits are
|
||||
// permanent (CUSIP->ticker mappings don't change), so a cached
|
||||
// answer is authoritative and skips OpenFIGI entirely.
|
||||
var cache_map = svc.loadCusipTickerMap(allocator);
|
||||
defer cache_map.deinit();
|
||||
if (cache_map.get(parsed.cusip)) |ticker| {
|
||||
const cached: zfin.CusipResult = .{
|
||||
.ticker = ticker,
|
||||
.name = null,
|
||||
.security_type = null,
|
||||
.found = true,
|
||||
};
|
||||
try display(cached, parsed.cusip, color, out);
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (from local cache)\n", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
|
||||
|
||||
// Try full batch lookup for richer output
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ pub const Holding = struct {
|
|||
symbol: ?[]const u8 = null,
|
||||
name: []const u8,
|
||||
weight: f64,
|
||||
/// CUSIP from the NPORT-P filing, when present. The join key for
|
||||
/// look-through resolution: NPORT-P identifies holdings by CUSIP
|
||||
/// far more reliably than by ticker, so `exposure` resolves this
|
||||
/// to a ticker (via `cusip_tickers.srf` / OpenFIGI) to match a
|
||||
/// holding against a directly-held position.
|
||||
cusip: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Sector allocation in an ETF.
|
||||
|
|
@ -70,6 +76,7 @@ pub const EtfProfile = struct {
|
|||
if (self.holdings) |h| {
|
||||
for (h) |holding| {
|
||||
if (holding.symbol) |s| allocator.free(s);
|
||||
if (holding.cusip) |c| allocator.free(c);
|
||||
allocator.free(holding.name);
|
||||
}
|
||||
allocator.free(h);
|
||||
|
|
|
|||
640
src/service.zig
640
src/service.zig
|
|
@ -208,6 +208,19 @@ pub const Source = enum {
|
|||
fetched,
|
||||
};
|
||||
|
||||
/// In-memory payload shape for a fetched type `T`.
|
||||
///
|
||||
/// Almost everything is a slice of records (`[]Candle`, `[]Dividend`,
|
||||
/// …) — the same shape the cache stores. `EtfProfile` is the lone
|
||||
/// exception: `getEtfProfile` assembles a single struct from the
|
||||
/// `etf_metrics` cache rather than returning a slice, so its payload
|
||||
/// is the struct itself. The cache layer never stores `EtfProfile`
|
||||
/// directly, which is why this single-struct knowledge lives here in
|
||||
/// the fetch layer rather than in `Store.DataFor`.
|
||||
fn PayloadFor(comptime T: type) type {
|
||||
return if (T == EtfProfile) EtfProfile else []T;
|
||||
}
|
||||
|
||||
/// Generic result type for all fetch operations: data payload + provenance metadata.
|
||||
///
|
||||
/// `data` is owned by `allocator` — call `result.deinit()` to release
|
||||
|
|
@ -217,7 +230,7 @@ pub const Source = enum {
|
|||
/// allocator (e.g. an arena) differed from the service's allocator.
|
||||
pub fn FetchResult(comptime T: type) type {
|
||||
return struct {
|
||||
data: cache.Store.DataFor(T),
|
||||
data: PayloadFor(T),
|
||||
source: Source,
|
||||
timestamp: i64,
|
||||
/// Allocator that owns `data`. Populated by the service on
|
||||
|
|
@ -1006,6 +1019,7 @@ pub const DataService = struct {
|
|||
for (holdings_buf.items) |h| {
|
||||
self.allocator.free(h.name);
|
||||
if (h.symbol) |s| self.allocator.free(s);
|
||||
if (h.cusip) |c| self.allocator.free(c);
|
||||
}
|
||||
holdings_buf.deinit(self.allocator);
|
||||
}
|
||||
|
|
@ -1026,10 +1040,19 @@ pub const DataService = struct {
|
|||
try self.allocator.dupe(u8, t)
|
||||
else
|
||||
null;
|
||||
errdefer if (sym_dup) |s| self.allocator.free(s);
|
||||
const cusip_dup: ?[]const u8 = if (h.cusip) |c|
|
||||
try self.allocator.dupe(u8, c)
|
||||
else
|
||||
null;
|
||||
errdefer if (cusip_dup) |c| self.allocator.free(c);
|
||||
const name_dup = try self.allocator.dupe(u8, h.name);
|
||||
errdefer self.allocator.free(name_dup);
|
||||
try holdings_buf.append(self.allocator, .{
|
||||
.symbol = sym_dup,
|
||||
.name = try self.allocator.dupe(u8, h.name),
|
||||
.name = name_dup,
|
||||
.weight = h.pct_of_portfolio / 100.0,
|
||||
.cusip = cusip_dup,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1864,10 +1887,10 @@ pub const DataService = struct {
|
|||
// No proactive token-bucket limiter for these. Tiingo
|
||||
// (candles) has a 1000/day quota enforced reactively
|
||||
// via 429-then-backoff in `getCandles`; Wikidata
|
||||
// (classification) has no published quota; the legacy
|
||||
// `etf_profile` and `meta` types aren't fetched. Nothing
|
||||
// useful to wait for at the call site, so report 0.
|
||||
.candles_daily, .candles_meta, .classification, .etf_profile, .meta => 0,
|
||||
// (classification) has no published quota; the `meta`
|
||||
// type isn't fetched. Nothing useful to wait for at the
|
||||
// call site, so report 0.
|
||||
.candles_daily, .candles_meta, .classification, .meta => 0,
|
||||
};
|
||||
return if (ns == 0) 0 else @max(1, ns / std.time.ns_per_s);
|
||||
}
|
||||
|
|
@ -2398,36 +2421,130 @@ pub const DataService = struct {
|
|||
ticker: []const u8 = "",
|
||||
};
|
||||
|
||||
/// Append a CUSIP->ticker mapping to the cache file.
|
||||
/// CUSIP->ticker lookup table loaded from `cusip_tickers.srf`.
|
||||
///
|
||||
/// Implemented as read-append-atomic-write (rather than a direct
|
||||
/// open-for-append) so a concurrent reader never sees a file with a
|
||||
/// valid header plus partial trailing record. See `cache/store.zig
|
||||
/// appendRaw` for the same pattern and rationale.
|
||||
pub fn cacheCusipTicker(self: *DataService, cusip: []const u8, ticker: []const u8) void {
|
||||
const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return;
|
||||
defer self.allocator.free(path);
|
||||
/// Zero-copy: keys and values are slices into `backing` (the raw
|
||||
/// file bytes parsed with `parse_allocator = .none`). Nothing is
|
||||
/// duped per entry — the whole-file buffer IS the storage, and it
|
||||
/// stays alive for the table's lifetime, released together with
|
||||
/// the map table in `deinit`.
|
||||
///
|
||||
/// This is the L1 tier of CUSIP resolution: callers consult it
|
||||
/// before reaching for the server or OpenFIGI.
|
||||
pub const CusipTickerMap = struct {
|
||||
map: std.StringHashMap([]const u8),
|
||||
/// Raw bytes of `cusip_tickers.srf`; every map key and value
|
||||
/// points into this buffer. `&.{}` when the file was missing
|
||||
/// or unreadable (freeing a zero-length slice is a no-op).
|
||||
backing: []const u8,
|
||||
|
||||
// Ensure cache dir exists
|
||||
if (std.fs.path.dirnamePosix(path)) |dir| {
|
||||
std.Io.Dir.cwd().createDirPath(self.io, dir) catch |err| log.warn("audit-log createDirPath({s}): {t}", .{ dir, err });
|
||||
pub fn get(self: CusipTickerMap, cusip: []const u8) ?[]const u8 {
|
||||
return self.map.get(cusip);
|
||||
}
|
||||
|
||||
// Read existing cache if present.
|
||||
const existing = std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(4 * 1024 * 1024)) catch |err| switch (err) {
|
||||
error.FileNotFound => @as([]u8, &.{}),
|
||||
else => return,
|
||||
};
|
||||
const owns_existing = existing.len > 0;
|
||||
defer if (owns_existing) self.allocator.free(existing);
|
||||
pub fn contains(self: CusipTickerMap, cusip: []const u8) bool {
|
||||
return self.map.contains(cusip);
|
||||
}
|
||||
|
||||
// Serialize the new entry (with `#!srfv1` directives only if the
|
||||
// cache file doesn't exist yet).
|
||||
const emit_directives = !owns_existing;
|
||||
const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
|
||||
pub fn count(self: CusipTickerMap) u32 {
|
||||
return self.map.count();
|
||||
}
|
||||
|
||||
/// Release the map table and the backing buffer. Both were
|
||||
/// allocated with the map's allocator at load time, so we
|
||||
/// reuse it here — the two lifetimes are bound together by
|
||||
/// construction, which is the whole point of the wrapper.
|
||||
pub fn deinit(self: *CusipTickerMap) void {
|
||||
const allocator = self.map.allocator;
|
||||
self.map.deinit();
|
||||
allocator.free(self.backing);
|
||||
}
|
||||
};
|
||||
|
||||
/// Load the CUSIP->ticker cache file into a `CusipTickerMap`. The
|
||||
/// returned table owns the file bytes; release it with
|
||||
/// `CusipTickerMap.deinit`.
|
||||
///
|
||||
/// Missing file → empty table (the common first-run case). First
|
||||
/// occurrence wins on duplicate CUSIPs, which tolerates the
|
||||
/// historical double-append bug in cache files written before
|
||||
/// `cacheCusipTicker` learned to dedup.
|
||||
///
|
||||
/// The on-disk format is CUSIP-keyed (`cusip::X,ticker::Y`); the
|
||||
/// returned map is keyed the same way for O(1) forward lookup.
|
||||
pub fn loadCusipTickerMap(self: *DataService, allocator: std.mem.Allocator) CusipTickerMap {
|
||||
const map = std.StringHashMap([]const u8).init(allocator);
|
||||
const path = std.fs.path.join(allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch
|
||||
return .{ .map = map, .backing = &.{} };
|
||||
defer allocator.free(path);
|
||||
|
||||
const data = std.Io.Dir.cwd().readFileAlloc(self.io, path, allocator, .limited(4 * 1024 * 1024)) catch
|
||||
return .{ .map = map, .backing = &.{} };
|
||||
// From here `data` is the table's backing store: keys and
|
||||
// values are slices into it (parse_allocator = .none, so the
|
||||
// parser borrows rather than copies). Freed by
|
||||
// `CusipTickerMap.deinit`, never here — that's the lifetime
|
||||
// contract that lets us skip per-entry dupes entirely.
|
||||
var result: CusipTickerMap = .{ .map = map, .backing = data };
|
||||
|
||||
var reader = std.Io.Reader.fixed(data);
|
||||
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return result;
|
||||
defer it.deinit();
|
||||
|
||||
while (it.next() catch return result) |fields| {
|
||||
const entry = fields.to(CusipEntry, .{}) catch continue;
|
||||
if (entry.cusip.len == 0 or entry.ticker.len == 0) continue;
|
||||
// First occurrence wins; getOrPut stores the borrowed
|
||||
// slices directly — they live in `backing`, no dupe.
|
||||
const gop = result.map.getOrPut(entry.cusip) catch continue;
|
||||
if (!gop.found_existing) gop.value_ptr.* = entry.ticker;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Append CUSIP->ticker mappings to `cusip_tickers.srf`, skipping
|
||||
/// any whose CUSIP is already on disk and any duplicates within
|
||||
/// `entries`. One read + one atomic write regardless of batch size.
|
||||
///
|
||||
/// Read-append-atomic-write (rather than open-for-append) so a
|
||||
/// concurrent reader never sees a valid header plus a partial
|
||||
/// trailing record — see `cache/store.zig appendRaw` for the same
|
||||
/// pattern and rationale. `#!srfv1` directives are emitted only
|
||||
/// when the file is being created.
|
||||
fn appendCusipEntries(self: *DataService, entries: []const CusipEntry) void {
|
||||
if (entries.len == 0) return;
|
||||
|
||||
// One load gives us both the dedup set and the existing bytes
|
||||
// to concat (`backing`). Missing/empty file → empty map + empty
|
||||
// backing → directives emitted below.
|
||||
var existing_map = self.loadCusipTickerMap(self.allocator);
|
||||
defer existing_map.deinit();
|
||||
const existing = existing_map.backing;
|
||||
|
||||
// Keep only entries new to the file and unique within the batch.
|
||||
var seen = std.StringHashMap(void).init(self.allocator);
|
||||
defer seen.deinit();
|
||||
var to_write: std.ArrayList(CusipEntry) = .empty;
|
||||
defer to_write.deinit(self.allocator);
|
||||
for (entries) |e| {
|
||||
if (e.cusip.len == 0 or e.ticker.len == 0) continue;
|
||||
if (existing_map.contains(e.cusip)) continue;
|
||||
const gop = seen.getOrPut(e.cusip) catch continue;
|
||||
if (gop.found_existing) continue;
|
||||
to_write.append(self.allocator, e) catch continue;
|
||||
}
|
||||
if (to_write.items.len == 0) return;
|
||||
|
||||
const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return;
|
||||
defer self.allocator.free(path);
|
||||
if (std.fs.path.dirnamePosix(path)) |dir| {
|
||||
std.Io.Dir.cwd().createDirPath(self.io, dir) catch |err| log.warn("cusip-cache createDirPath({s}): {t}", .{ dir, err });
|
||||
}
|
||||
|
||||
const emit_directives = existing.len == 0;
|
||||
var aw: std.Io.Writer.Allocating = .init(self.allocator);
|
||||
defer aw.deinit();
|
||||
aw.writer.print("{f}", .{srf.fmt(CusipEntry, &entry, .{ .emit_directives = emit_directives })}) catch return;
|
||||
aw.writer.print("{f}", .{srf.fmt(CusipEntry, to_write.items, .{ .emit_directives = emit_directives })}) catch return;
|
||||
const encoded = aw.writer.buffered();
|
||||
if (encoded.len == 0) return;
|
||||
|
||||
|
|
@ -2437,7 +2554,190 @@ pub const DataService = struct {
|
|||
@memcpy(combined[0..existing.len], existing);
|
||||
@memcpy(combined[existing.len..], encoded);
|
||||
|
||||
atomic.writeFileAtomic(self.io, self.allocator, path, combined) catch |err| log.warn("audit-log writeFileAtomic({s}): {t}", .{ path, err });
|
||||
atomic.writeFileAtomic(self.io, self.allocator, path, combined) catch |err| log.warn("cusip-cache writeFileAtomic({s}): {t}", .{ path, err });
|
||||
}
|
||||
|
||||
/// Append a single CUSIP->ticker mapping to the cache file
|
||||
/// (dedup-aware). Thin wrapper over `appendCusipEntries`; the
|
||||
/// `lookup` command's single-CUSIP path.
|
||||
pub fn cacheCusipTicker(self: *DataService, cusip: []const u8, ticker: []const u8) void {
|
||||
self.appendCusipEntries(&.{.{ .cusip = cusip, .ticker = ticker }});
|
||||
}
|
||||
|
||||
/// Resolve a set of CUSIPs to tickers via the three-tier cascade,
|
||||
/// persisting newly-learned mappings to `cusip_tickers.srf` (union
|
||||
/// policy: the local file accumulates everything it ever learns and
|
||||
/// converges toward the shared server set).
|
||||
///
|
||||
/// Tiers, cheapest first:
|
||||
/// L1 local `cusip_tickers.srf` (always; no network)
|
||||
/// 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
|
||||
/// and covers every CUSIP any tier could resolve. Callers resolve
|
||||
/// forward-per-holding: look up each holding's CUSIP against it,
|
||||
/// which sidesteps the "do I have every CUSIP for this ticker?"
|
||||
/// completeness problem entirely.
|
||||
///
|
||||
/// 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, skip_network: bool) CusipTickerMap {
|
||||
var result = self.loadCusipTickerMap(allocator);
|
||||
|
||||
// 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.
|
||||
var scratch = std.heap.ArenaAllocator.init(self.allocator);
|
||||
defer scratch.deinit();
|
||||
const sa = scratch.allocator();
|
||||
var minted = std.StringHashMap([]const u8).init(sa); // cusip -> ticker
|
||||
|
||||
// L2: server whole-file sync. Degrades to no-op until the
|
||||
// `GET /cusips` route exists (a 404 surfaces as NotFound from
|
||||
// client.get); when it lands it's purely additive — no change
|
||||
// here. The server is expected to serve the file via its
|
||||
// existing `handleStaticSrfFile` machinery (same shape as
|
||||
// `/_edgar/tickers_funds`).
|
||||
if (self.config.server_url) |server_url| {
|
||||
if (self.fetchServerCusips(server_url)) |body| {
|
||||
defer self.allocator.free(body);
|
||||
mergeCusipBody(sa, &minted, result, body);
|
||||
}
|
||||
}
|
||||
|
||||
// L3: OpenFIGI for whatever still misses.
|
||||
self.mintMissingViaOpenFigi(sa, &minted, result, cusips);
|
||||
|
||||
if (minted.count() == 0) return result; // nothing new learned
|
||||
|
||||
// Persist the union, then reload so the returned map is a clean
|
||||
// single-buffer zero-copy view over the updated file.
|
||||
var ents: std.ArrayList(CusipEntry) = .empty;
|
||||
// Reserve up front so the collection loop is infallible. On OOM
|
||||
// (vanishingly unlikely for a small list), skip persistence and
|
||||
// return the L1 view — some CUSIPs stay unresolved this run
|
||||
// rather than erroring.
|
||||
ents.ensureTotalCapacity(sa, minted.count()) catch return result;
|
||||
var mit = minted.iterator();
|
||||
while (mit.next()) |kv| ents.appendAssumeCapacity(.{ .cusip = kv.key_ptr.*, .ticker = kv.value_ptr.* });
|
||||
self.appendCusipEntries(ents.items);
|
||||
|
||||
result.deinit();
|
||||
return self.loadCusipTickerMap(allocator);
|
||||
}
|
||||
|
||||
/// True if any non-empty CUSIP in `cusips` is absent from `map`.
|
||||
fn anyMissing(map: CusipTickerMap, cusips: []const []const u8) bool {
|
||||
for (cusips) |c| {
|
||||
if (c.len == 0) continue;
|
||||
if (!map.contains(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Merge a CUSIP->ticker SRF body (as served by `GET /cusips`) into
|
||||
/// `out`, skipping any CUSIP already present in `have` or `out`.
|
||||
/// Strings are duped into `arena`. Pure with respect to I/O, so it's
|
||||
/// unit-tested directly with fixture bytes (the live L2 path can't
|
||||
/// be exercised until the server route exists).
|
||||
fn mergeCusipBody(arena: std.mem.Allocator, out: *std.StringHashMap([]const u8), have: CusipTickerMap, body: []const u8) void {
|
||||
var reader = std.Io.Reader.fixed(body);
|
||||
var it = srf.iterator(&reader, arena, .{ .parse_allocator = .none }) catch return;
|
||||
defer it.deinit();
|
||||
while (it.next() catch return) |fields| {
|
||||
const e = fields.to(CusipEntry, .{}) catch continue;
|
||||
if (e.cusip.len == 0 or e.ticker.len == 0) continue;
|
||||
if (have.contains(e.cusip) or out.contains(e.cusip)) continue;
|
||||
const kc = arena.dupe(u8, e.cusip) catch continue;
|
||||
const vc = arena.dupe(u8, e.ticker) catch continue;
|
||||
out.put(kc, vc) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// L2 seam: fetch the whole CUSIP->ticker map from the server via
|
||||
/// `GET {server}/cusips`. Returns the raw SRF body (caller frees
|
||||
/// with `self.allocator`) or null on any failure. Best-effort: no
|
||||
/// retry and no torn-body archival (this is a shared reference
|
||||
/// file, not per-symbol cache) — a bad/absent response just
|
||||
/// degrades to the OpenFIGI tier.
|
||||
fn fetchServerCusips(self: *DataService, server_url: []const u8) ?[]u8 {
|
||||
const url = std.fmt.allocPrint(self.allocator, "{s}/cusips", .{server_url}) catch return null;
|
||||
defer self.allocator.free(url);
|
||||
|
||||
var client = http.Client.init(self.io, self.allocator);
|
||||
defer client.deinit();
|
||||
|
||||
var response = client.get(url) catch |err| {
|
||||
log.debug("cusips server sync failed: {s}", .{@errorName(err)});
|
||||
return null;
|
||||
};
|
||||
defer response.deinit();
|
||||
|
||||
if (!cache.Store.looksCompleteSrf(response.body)) {
|
||||
log.debug("cusips server response not complete SRF ({d} bytes) — ignoring", .{response.body.len});
|
||||
return null;
|
||||
}
|
||||
return self.allocator.dupe(u8, response.body) catch null;
|
||||
}
|
||||
|
||||
/// L3: resolve still-missing CUSIPs through OpenFIGI (batched 100
|
||||
/// per request, the API's job limit), recording hits into `out`
|
||||
/// (duped into `arena`). De-dups the lookup set against `have`,
|
||||
/// `out`, and itself. Best-effort: a failed batch logs and is
|
||||
/// skipped; remaining batches still run.
|
||||
fn mintMissingViaOpenFigi(self: *DataService, arena: std.mem.Allocator, out: *std.StringHashMap([]const u8), have: CusipTickerMap, cusips: []const []const u8) void {
|
||||
var seen = std.StringHashMap(void).init(arena);
|
||||
var to_lookup: std.ArrayList([]const u8) = .empty;
|
||||
for (cusips) |c| {
|
||||
if (c.len == 0) continue;
|
||||
if (have.contains(c) or out.contains(c)) continue;
|
||||
const gop = seen.getOrPut(c) catch continue;
|
||||
if (gop.found_existing) continue;
|
||||
to_lookup.append(arena, c) catch continue;
|
||||
}
|
||||
if (to_lookup.items.len == 0) return;
|
||||
|
||||
const batch_size = 100; // OpenFIGI accepts up to 100 jobs/request.
|
||||
var start: usize = 0;
|
||||
while (start < to_lookup.items.len) : (start += batch_size) {
|
||||
const end = @min(start + batch_size, to_lookup.items.len);
|
||||
const batch = to_lookup.items[start..end];
|
||||
|
||||
const figi = self.lookupCusips(batch) catch |err| {
|
||||
log.warn("resolveCusips: OpenFIGI lookup of {d} CUSIP(s) failed: {s}", .{ batch.len, @errorName(err) });
|
||||
continue;
|
||||
};
|
||||
defer {
|
||||
for (figi) |r| {
|
||||
if (r.ticker) |t| self.allocator.free(t);
|
||||
if (r.name) |n| self.allocator.free(n);
|
||||
if (r.security_type) |s| self.allocator.free(s);
|
||||
}
|
||||
self.allocator.free(figi);
|
||||
}
|
||||
|
||||
// Results are parallel to `batch` (same length + order).
|
||||
for (figi, 0..) |r, i| {
|
||||
if (!r.found) continue;
|
||||
const ticker = r.ticker orelse continue;
|
||||
const kc = arena.dupe(u8, batch[i]) catch continue;
|
||||
const vc = arena.dupe(u8, ticker) catch continue;
|
||||
out.put(kc, vc) catch continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──────────────────────────────────────────────────
|
||||
|
|
@ -2478,7 +2778,6 @@ pub const DataService = struct {
|
|||
.earnings => "/earnings",
|
||||
.options => "/options",
|
||||
.splits => "/splits",
|
||||
.etf_profile => return false, // not served (replaced by etf_metrics)
|
||||
.meta => return false,
|
||||
.classification => "/classification",
|
||||
.etf_metrics => "/etf_metrics",
|
||||
|
|
@ -3595,7 +3894,6 @@ test "estimateWaitSeconds returns 0 for types without rate limiters" {
|
|||
try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_daily));
|
||||
try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_meta));
|
||||
try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.classification));
|
||||
try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.etf_profile));
|
||||
try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.meta));
|
||||
}
|
||||
|
||||
|
|
@ -3778,3 +4076,281 @@ test "freeEdgarLookup: handles all three union variants without leak" {
|
|||
// testing.allocator panics on leak — passing this test means
|
||||
// the title was freed.
|
||||
}
|
||||
|
||||
// ── CUSIP->ticker cache (loadCusipTickerMap / cacheCusipTicker) ──
|
||||
|
||||
test "loadCusipTickerMap: missing file returns empty map" {
|
||||
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();
|
||||
|
||||
var map = svc.loadCusipTickerMap(allocator);
|
||||
defer map.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 0), map.count());
|
||||
}
|
||||
|
||||
test "cacheCusipTicker + loadCusipTickerMap: write/read round-trip" {
|
||||
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();
|
||||
|
||||
// Placeholder CUSIPs/tickers — never real PII.
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
svc.cacheCusipTicker("222222222", "BBB");
|
||||
|
||||
var map = svc.loadCusipTickerMap(allocator);
|
||||
defer map.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 2), map.count());
|
||||
try std.testing.expectEqualStrings("AAA", map.get("111111111").?);
|
||||
try std.testing.expectEqualStrings("BBB", map.get("222222222").?);
|
||||
}
|
||||
|
||||
test "cacheCusipTicker: dedups repeated CUSIP (the historical bug)" {
|
||||
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();
|
||||
|
||||
// Write the same CUSIP three times — must collapse to one row.
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
|
||||
var map = svc.loadCusipTickerMap(allocator);
|
||||
defer map.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 1), map.count());
|
||||
try std.testing.expectEqualStrings("AAA", map.get("111111111").?);
|
||||
|
||||
// The on-disk file should physically contain exactly one data
|
||||
// row (plus the directive header), proving dedup at the writer.
|
||||
const path = try std.fs.path.join(allocator, &.{ dir_path, "cusip_tickers.srf" });
|
||||
defer allocator.free(path);
|
||||
const data = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024));
|
||||
defer allocator.free(data);
|
||||
var row_count: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (std.mem.indexOf(u8, line, "cusip::") != null) row_count += 1;
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 1), row_count);
|
||||
}
|
||||
|
||||
test "loadCusipTickerMap: first occurrence wins on duplicate rows" {
|
||||
// Tolerate a pre-existing file written by the buggy appender
|
||||
// (duplicate rows). The reader must not crash and must keep the
|
||||
// first mapping.
|
||||
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);
|
||||
|
||||
// Hand-write a file with a duplicate row (as the old bug did).
|
||||
const path = try std.fs.path.join(allocator, &.{ dir_path, "cusip_tickers.srf" });
|
||||
defer allocator.free(path);
|
||||
try std.Io.Dir.cwd().writeFile(io, .{
|
||||
.sub_path = path,
|
||||
.data = "#!srfv1\ncusip::111111111,ticker::AAA\ncusip::111111111,ticker::AAA\n",
|
||||
});
|
||||
|
||||
var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path });
|
||||
defer svc.deinit();
|
||||
|
||||
var map = svc.loadCusipTickerMap(allocator);
|
||||
defer map.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 1), map.count());
|
||||
try std.testing.expectEqualStrings("AAA", map.get("111111111").?);
|
||||
}
|
||||
|
||||
// ── CUSIP resolution cascade (resolveCusips / appendCusipEntries) ──
|
||||
|
||||
test "appendCusipEntries: batches, dedups vs file and within batch" {
|
||||
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();
|
||||
|
||||
// Seed one entry on disk.
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
|
||||
// Batch: 111 already on disk (skip), 222 + 333 new, 222 repeated
|
||||
// within the batch (skip the second).
|
||||
const batch = [_]DataService.CusipEntry{
|
||||
.{ .cusip = "111111111", .ticker = "ZZZ" },
|
||||
.{ .cusip = "222222222", .ticker = "BBB" },
|
||||
.{ .cusip = "333333333", .ticker = "CCC" },
|
||||
.{ .cusip = "222222222", .ticker = "BBB" },
|
||||
};
|
||||
svc.appendCusipEntries(batch[0..]);
|
||||
|
||||
var map = svc.loadCusipTickerMap(allocator);
|
||||
defer map.deinit();
|
||||
try std.testing.expectEqual(@as(u32, 3), map.count());
|
||||
try std.testing.expectEqualStrings("AAA", map.get("111111111").?); // file wins
|
||||
try std.testing.expectEqualStrings("BBB", map.get("222222222").?);
|
||||
try std.testing.expectEqualStrings("CCC", map.get("333333333").?);
|
||||
|
||||
// Physically exactly 3 data rows (plus the directive header).
|
||||
const path = try std.fs.path.join(allocator, &.{ dir_path, "cusip_tickers.srf" });
|
||||
defer allocator.free(path);
|
||||
const data = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024));
|
||||
defer allocator.free(data);
|
||||
var rows: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (std.mem.indexOf(u8, line, "cusip::") != null) rows += 1;
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 3), rows);
|
||||
}
|
||||
|
||||
test "mergeCusipBody: merges new entries, skips those already in `have` or the batch" {
|
||||
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();
|
||||
|
||||
// `have` already maps 111 -> AAA (local is authoritative).
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
var have = svc.loadCusipTickerMap(allocator);
|
||||
defer have.deinit();
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
var out = std.StringHashMap([]const u8).init(arena.allocator());
|
||||
|
||||
// Server body: 111 conflicts with `have` (ignored), 222 + 333 are
|
||||
// new, 222 repeated (the second is skipped).
|
||||
const body =
|
||||
"#!srfv1\n" ++
|
||||
"cusip::111111111,ticker::ZZZ\n" ++
|
||||
"cusip::222222222,ticker::BBB\n" ++
|
||||
"cusip::333333333,ticker::CCC\n" ++
|
||||
"cusip::222222222,ticker::BBB\n";
|
||||
DataService.mergeCusipBody(arena.allocator(), &out, have, body);
|
||||
|
||||
try std.testing.expectEqual(@as(u32, 2), out.count());
|
||||
try std.testing.expectEqualStrings("BBB", out.get("222222222").?);
|
||||
try std.testing.expectEqualStrings("CCC", out.get("333333333").?);
|
||||
try std.testing.expect(out.get("111111111") == null); // have wins
|
||||
}
|
||||
|
||||
test "resolveCusips: warm cache resolves without touching 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();
|
||||
// No server_url; assert L2/L3 are never reached for an all-hit set.
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
svc.cacheCusipTicker("111111111", "AAA");
|
||||
svc.cacheCusipTicker("222222222", "BBB");
|
||||
|
||||
// Duplicate + empty CUSIP in the request must be tolerated.
|
||||
const want = [_][]const u8{ "111111111", "222222222", "111111111", "" };
|
||||
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;
|
||||
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();
|
||||
|
||||
// Seed etf_metrics: a profile row + a holding carrying a CUSIP but
|
||||
// no ticker (the common NPORT-P shape — placeholder values only).
|
||||
var etf_records = [_]Edgar.EtfMetricRecord{
|
||||
.{ .profile = .{
|
||||
.symbol = try allocator.dupe(u8, "TESTF"),
|
||||
.series_name = try allocator.dupe(u8, "Test Fund"),
|
||||
.cik = try allocator.dupe(u8, "0000000002"),
|
||||
.as_of = try allocator.dupe(u8, "2026-06-01"),
|
||||
.source = try allocator.dupe(u8, "edgar"),
|
||||
} },
|
||||
.{ .holding = .{
|
||||
.symbol = try allocator.dupe(u8, "TESTF"),
|
||||
.name = try allocator.dupe(u8, "Placeholder Corp"),
|
||||
.cusip = try allocator.dupe(u8, "999999999"),
|
||||
.pct_of_portfolio = 12.5,
|
||||
.as_of = try allocator.dupe(u8, "2026-06-01"),
|
||||
.source = try allocator.dupe(u8, "edgar"),
|
||||
} },
|
||||
};
|
||||
defer for (etf_records) |r| r.deinit(allocator);
|
||||
var s = svc.store();
|
||||
s.write(Edgar.EtfMetricRecord, "TESTF", etf_records[0..], cache.DataType.etf_metrics.ttl());
|
||||
|
||||
svc.panic_on_network_attempt = true;
|
||||
const result = try svc.getEtfProfile("TESTF", .{ .skip_network = true });
|
||||
defer result.deinit();
|
||||
|
||||
const holdings = result.data.holdings orelse return error.NoHoldings;
|
||||
try std.testing.expectEqual(@as(usize, 1), holdings.len);
|
||||
try std.testing.expectEqualStrings("999999999", holdings[0].cusip orelse return error.NoCusip);
|
||||
try std.testing.expect(holdings[0].symbol == null); // filing had no ticker
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue