Compare commits

...

5 commits

Author SHA1 Message Date
1c4f85f8da
add exposure command
All checks were successful
Generic zig build / build (push) Successful in 5m58s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 27s
2026-06-17 12:50:42 -07:00
beb8ed156b
workaround for prek on riscv64. zlint still tbd 2026-06-17 10:41:08 -07:00
78ffecba4f
remove remaining AlphaVantage code 2026-06-16 19:44:30 -07:00
867f9afb8c
add cusip to etf profile 2026-06-16 16:57:27 -07:00
415071b955
dedup cusip cache and actually use the cache 2026-06-16 16:00:53 -07:00
9 changed files with 1432 additions and 163 deletions

View file

@ -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
View 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 CUSIPticker 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
View file

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

View file

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

View file

@ -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

View file

@ -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"),

View file

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

View file

@ -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
}