//! EDGAR provider - SEC's electronic filing system as a data source. //! //! ## What this provider does //! //! Given a stock or fund symbol, EDGAR can answer: //! //! * "What's this fund made of?" - the latest portfolio holdings, //! sector breakdown, and net assets, parsed from the fund's most //! recent NPORT-P filing. //! * "How many shares does this company have outstanding?" - read //! from XBRL-tagged fields on the company's most recent 10-K / //! 10-Q / 40-F cover page. Combined with a price quote (from //! elsewhere) this gives market cap. //! * "Where in EDGAR does this symbol live?" - symbol -> CIK //! lookup via SEC's two ticker-map indexes. //! //! ## Workflow when a caller asks about one symbol //! //! Symbols don't carry CIKs, so the first step is always a //! ticker-map lookup. From there the path forks: //! //! AAPL (operating company) //! 1. Look up "AAPL" in the company ticker map -> CIK 320193. //! 2. Fetch the submissions feed for CIK 320193 -> entityType //! "operating", no NPORT-P. Classify as `not_a_fund`. //! 3. (Optional) fetch shares-outstanding from the XBRL //! companyconcept endpoint for use in market cap math. //! //! VTI (mutual-fund-trust ETF) //! 1. Look up "VTI" in the mutual-fund ticker map -> CIK 36405, //! seriesId S000002848. //! 2. Run the EDGAR full-text search for that seriesId, filtered //! to NPORT-P. Get the URL of the most recent filing. //! 3. Download the NPORT-P primary_doc.xml. Parse profile, //! sectors, holdings. //! //! SPY (unit-investment-trust ETF) //! 1. Not in mutual-fund ticker map. Look up "SPY" in the //! company ticker map -> CIK 884394. //! 2. Fetch the submissions feed -> entityType "other", has a //! NPORT-P at trust-CIK level (UITs don't have a seriesId). //! 3. Download that NPORT-P. Parse like a fund. //! //! GLD (commodity trust) //! 1. Not in mutual-fund ticker map. Look up "GLD" in the //! company ticker map -> CIK 1222333. //! 2. Submissions feed -> entityType "operating", SIC describes a //! commodity trust. No NPORT-P. Return profile-only metrics //! (the trust exists but has no portfolio to disclose). //! //! ## Glossary //! //! CIK Central Index Key. SEC's primary identifier for a //! filer. 10-digit zero-padded number; we normalize to //! that shape at the boundary so all callers can //! assume it. //! NPORT-P Form NPORT-P (public). Quarterly portfolio //! disclosure filed by registered investment companies //! (mutual funds, most ETFs). Contains every position, //! aggregated valuation, and asset/issuer classifiers. //! 10-K Annual report filed by US-domiciled operating //! companies. Cover page carries shares-outstanding. //! 10-Q Quarterly equivalent of 10-K. //! 40-F Annual report filed by Canadian companies that //! participate in the SEC's MJDS regime. Same XBRL //! cover-page fields as 10-K - the dei taxonomy //! handles both. Barrick Mining, Shopify, etc. //! 20-F Annual report filed by other foreign private //! issuers (BP, Toyota, Sony, ...). Covers the same //! financial-statement ground as 10-K but the SEC //! doesn't require dei-tagged shares-outstanding here, //! so the XBRL companyconcept endpoint returns 404 for //! many of them. Caller treats this as "shares unknown." //! XBRL Structured-data tagging for SEC filings. Makes //! specific fields (revenue, shares outstanding, etc.) //! machine-readable across forms. //! dei Document and Entity Information - XBRL taxonomy for //! cover-page metadata (entity name, registrant info, //! shares outstanding). Cross-form, cross-jurisdiction. //! us-gaap XBRL taxonomy for US GAAP financial concepts. //! Carries fallback shares-outstanding for dual-class //! issuers (GOOGL, META) that don't tag the dei field. //! UIT Unit Investment Trust. A specific kind of fund //! structure (SPY, GLD, IVV, ...) that files NPORT-P //! at the trust-CIK level rather than under a //! series-of-trust seriesId like mutual funds do. //! SIC Standard Industrial Classification. Four-digit //! industry code on the submissions feed; we use it to //! distinguish commodity trusts (SIC 6221) from //! operating companies (most other codes). //! //! ## SEC endpoints used //! //! 1. https://www.sec.gov/files/company_tickers_mf.json //! Mutual fund and ETF ticker map: (ticker -> CIK, seriesId, //! classId). One file, ~3 MB. //! //! 2. https://www.sec.gov/files/company_tickers.json //! Stocks and unit-investment-trust ETFs: (ticker -> CIK, //! title). One file, ~5 MB. //! //! 3. https://efts.sec.gov/LATEST/search-index?q=&forms=NPORT-P //! Full-text search for NPORT-P filings referencing //! `seriesId`. Necessary because the submissions feed only //! lists at trust-CIK level - a trust hosting hundreds of //! series would otherwise force us to download every NPORT-P //! to find the one we want. //! //! 4. https://data.sec.gov/submissions/CIK{cik:0>10}.json //! Per-CIK submissions feed. Carries entityType, //! sicDescription, ticker list, and the most-recent NPORT-P URL //! for UIT-style ETFs that lack a seriesId. //! //! 5. https://www.sec.gov/Archives/edgar/data///primary_doc.xml //! The actual NPORT-P document. XML, ~50-100 MB depending on //! fund size. //! //! 6. https://data.sec.gov/api/xbrl/companyconcept/CIK{cik:0>10}/{taxonomy}/{Concept}.json //! XBRL companyconcept endpoint. Used for shares-outstanding //! via `dei:EntityCommonStockSharesOutstanding` (single-class //! issuers) with fallback to `us-gaap:CommonStockSharesOutstanding` //! (dual-class issuers like GOOGL, META). //! //! ## Politeness //! //! SEC requires a descriptive User-Agent + From: header on every //! request, populated from `Config.user_email` (env //! `ZFIN_USER_EMAIL`). The provider takes the email as a non-null //! constructor argument; callers must surface a clear error if the //! env var is missing rather than letting requests go out //! un-identified. SEC's documented ceiling is 10 req/s per IP; we //! throttle at 8 req/s via a `RateLimiter`, leaving a 20% margin //! against timing jitter and retry bursts. A per-symbol loop over a //! typical portfolio reaches this ceiling quickly without it. //! //! ## Caching //! //! `Edgar` carries no cache state of its own. Every method does HTTP //! + parse and returns a typed result; the `DataService` layer //! writes the parsed results to the user-facing cache files //! (`classification.srf`, `etf_metrics.srf`, `entity_facts.srf`) //! and reads them back on subsequent calls. //! //! Ticker maps (`company_tickers*.json`) are the one upstream //! document we cache through `Store` - typed //! `[]MutualFundTickerEntry` / `[]CompanyTickerEntry` slices under //! a synthetic `_edgar` key - because they're refreshed at SEC's //! daily cadence rather than per symbol. Everything else gets //! parsed into typed records and //! written to the user-facing per-symbol or per-CIK cache files. const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); const fmt = @import("../format.zig"); const xml = @import("xml.zig"); const tickers_funds_url = "https://www.sec.gov/files/company_tickers_mf.json"; const tickers_companies_url = "https://www.sec.gov/files/company_tickers.json"; const search_url_prefix = "https://efts.sec.gov/LATEST/search-index?"; // ── Edgar provider state ───────────────────────────────────────── // // File-as-struct: the file's top-level fields and methods together // form the `Edgar` provider. Callers do // `const Edgar = @import("providers/Edgar.zig");` followed by // `var ed = Edgar.init(...);` and `ed.fetchMutualFundTickerMap(...)` // etc. client: http.Client, /// Contact email for the User-Agent + From headers SEC requires on /// every request. Sourced from `Config.user_email`. Required, not /// optional: callers must surface a clear missing-config error /// before constructing this provider rather than letting requests /// go out un-identified. user_email: []const u8, /// Token-bucket throttle keeping us under SEC's 10 req/s ceiling. /// Sized at 8 req/s to leave a 20% margin against timing jitter and /// any retry bursts. Per-symbol fetch loops over a portfolio reach /// this ceiling quickly without it. rate_limiter: RateLimiter, allocator: std.mem.Allocator, const Edgar = @This(); pub fn init(io: std.Io, allocator: std.mem.Allocator, user_email: []const u8) Edgar { return .{ .client = http.Client.init(io, allocator), .user_email = user_email, .rate_limiter = RateLimiter.init(io, 8, std.time.ns_per_s), .allocator = allocator, }; } pub fn deinit(self: *Edgar) void { self.client.deinit(); } /// GET wrapper that attaches the User-Agent + From headers SEC /// requires on every request and acquires a rate-limit token before /// issuing the call. fn httpGet(self: *Edgar, url: []const u8) !http.Response { self.rate_limiter.acquire(); var ua_buf: [256]u8 = undefined; const ua = std.fmt.bufPrint(&ua_buf, "zfin/0.1 ({s})", .{self.user_email}) catch return error.UserEmailTooLong; const headers = [_]std.http.Header{ .{ .name = "User-Agent", .value = ua }, .{ .name = "From", .value = self.user_email }, }; return self.client.request(.GET, url, null, &headers); } /// Fetch and parse SEC's mutual-fund/ETF ticker map /// (`company_tickers_mf.json`). Returns an owned slice of /// `MutualFundTickerEntry`. Caller manages the slice lifetime /// (caching is the DataService's job); typical usage is to hand /// the slice to `TickerMap(MutualFundTickerEntry).fromEntries` /// which takes ownership. pub fn fetchMutualFundTickerMap(self: *Edgar, allocator: std.mem.Allocator) ![]MutualFundTickerEntry { var resp = try self.httpGet(tickers_funds_url); defer resp.deinit(); return parseTickerMap(allocator, resp.body); } /// Fetch and parse SEC's stocks-and-UITs ticker map /// (`company_tickers.json`). Despite the filename, this file covers /// operating companies AND unit investment trust ETFs (SPY, GLD, /// IVV) - anything that doesn't file under a series-of-trust shape. /// Returns an owned slice of `CompanyTickerEntry`. pub fn fetchCompanyTickerMap(self: *Edgar, allocator: std.mem.Allocator) ![]CompanyTickerEntry { var resp = try self.httpGet(tickers_companies_url); defer resp.deinit(); return parseStockTickerMap(allocator, resp.body); } /// Find the most recent NPORT-P filing for `series_id`. Returns null /// if no filing exists. Caller owns the returned URL. pub fn findLatestNportP( self: *Edgar, allocator: std.mem.Allocator, series_id: []const u8, ) !?[]u8 { const url = try std.fmt.allocPrint( allocator, "{s}q=%22{s}%22&forms=NPORT-P", .{ search_url_prefix, series_id }, ); defer allocator.free(url); var resp = try self.httpGet(url); defer resp.deinit(); return parseLatestNportPFromSearch(allocator, resp.body); } /// Find the most recent NPORT-P filing for a CIK. Used for UIT-style /// ETFs (SPY, etc.) that file at the trust-CIK level rather than a /// series. Returns null if the CIK has no NPORT-P. pub fn findLatestNportPByCik( self: *Edgar, allocator: std.mem.Allocator, cik: []const u8, ) !?[]u8 { const sub = try self.fetchSubmissionsFeed(allocator, cik); return sub.latest_nport_p_url; } /// Fetch and parse the per-CIK submissions feed. pub fn fetchSubmissionsFeed( self: *Edgar, allocator: std.mem.Allocator, cik: []const u8, ) !SubmissionsSummary { const url = try std.fmt.allocPrint( allocator, "https://data.sec.gov/submissions/CIK{s:0>10}.json", .{cik}, ); defer allocator.free(url); var resp = try self.httpGet(url); defer resp.deinit(); return parseSubmissionsFeed(allocator, resp.body, cik); } /// Fetch the most recent shares-outstanding value for a CIK. Returns /// null on 404 (e.g. 20-F-only filers). Cascades through /// `dei:EntityCommonStockSharesOutstanding` (single-class) then /// `us-gaap:CommonStockSharesOutstanding` (dual-class fallback, /// e.g. GOOGL, META). pub fn fetchSharesOutstanding( self: *Edgar, allocator: std.mem.Allocator, cik: []const u8, ) !?SharesOutstanding { if (try self.fetchSharesConcept(allocator, cik, "dei", "EntityCommonStockSharesOutstanding")) |so| { return so; } return try self.fetchSharesConcept(allocator, cik, "us-gaap", "CommonStockSharesOutstanding"); } fn fetchSharesConcept( self: *Edgar, allocator: std.mem.Allocator, cik: []const u8, taxonomy: []const u8, concept: []const u8, ) !?SharesOutstanding { const url = try std.fmt.allocPrint( allocator, "https://data.sec.gov/api/xbrl/companyconcept/CIK{s:0>10}/{s}/{s}.json", .{ cik, taxonomy, concept }, ); defer allocator.free(url); var resp = self.httpGet(url) catch |err| { if (err == error.NotFound) return null; return err; }; defer resp.deinit(); return parseSharesOutstanding(allocator, resp.body); } /// Fetch and parse N-PORT-P metrics for one ETF/MF ticker. The /// return value describes what was found (full holdings, /// profile-only, or not-a-fund). `top_n_holdings` caps holdings /// emitted (sorted by pctVal descending). pub fn fetchEtfMetrics( self: *Edgar, io: std.Io, allocator: std.mem.Allocator, mf_ticker_map: *const TickerMap(MutualFundTickerEntry), stock_ticker_map: *const TickerMap(CompanyTickerEntry), symbol: []const u8, top_n_holdings: usize, ) !EtfMetricsResult { // MF/ETF map first - authoritative for symbols filed under a // series. Series-keyed full-text search; CIK fallback would // yield arbitrary other series under the same trust. if (mf_ticker_map.get(symbol)) |entry| { const filing_url = (try self.findLatestNportP(allocator, entry.series_id.?)) orelse { return .not_a_fund; }; defer allocator.free(filing_url); const m = try self.fetchAndParseNportP( io, allocator, entry.toGeneric(), filing_url, symbol, top_n_holdings, ); return .{ .full = m }; } // Stock map: probe the submissions feed (one extra HTTP per // unique CIK) to classify the entity. Branches: // - fund_shaped + has NPORT-P -> full holdings (SPY) // - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer) // - trust_shaped -> profile-only (GLD commodity) // - operating -> not-a-fund (AAPL, MSFT) if (stock_ticker_map.get(symbol)) |entry| { var sub = try self.fetchSubmissionsFeed(allocator, entry.cik); defer sub.deinit(allocator); const class = classifyByEntityType(&sub); switch (class) { .operating => return .not_a_fund, .fund_shaped => { if (sub.latest_nport_p_url) |url| { const m = try self.fetchAndParseNportP( io, allocator, entry.toGeneric(), url, symbol, top_n_holdings, ); return .{ .full = m }; } const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol); return .{ .profile_only = profile }; }, .trust_shaped => { // Skip the NPORT-P probe - by definition these // don't file one. Saves an HTTP roundtrip. const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol); return .{ .profile_only = profile }; }, } } return .not_in_edgar; } /// Download and parse a NPORT-P primary_doc.xml at `filing_url`. /// Used by both the MF and UIT paths in `fetchEtfMetrics`. The /// parsed `EtfMetrics` is the cacheable artifact; the XML bytes are /// discarded after parsing - no provider-internal XML cache, so /// re-fetches always re-download. /// /// `entry` is a `GenericTickerEntry`; callers from either ticker /// map (`MutualFundTickerEntry` / `CompanyTickerEntry`) collapse /// to this shape via `.toGeneric()`. fn fetchAndParseNportP( self: *Edgar, io: std.Io, allocator: std.mem.Allocator, entry: GenericTickerEntry, filing_url: []const u8, symbol: []const u8, top_n_holdings: usize, ) !EtfMetrics { var resp = try self.httpGet(filing_url); defer resp.deinit(); return parseNportP( io, allocator, resp.body, symbol, entry, top_n_holdings, ); } // ── Free types and helpers (no `self`) ─────────────────────────── pub const SectorWeight = struct { code: []const u8, // owned; raw NPORT-P code, e.g. "EC/CORP" description: []const u8, // owned; human-readable, e.g. "Equity / Corporate" pct_of_portfolio: f64, }; pub const Holding = struct { name: []const u8, // owned ticker: ?[]const u8 = null, // owned; present for some equity holdings cusip: ?[]const u8 = null, // owned lei: ?[]const u8 = null, // owned; ISO 17442 Legal Entity Identifier country: ?[]const u8 = null, // owned; ISO-3166 alpha-2 from pct_of_portfolio: f64, }; pub const EtfMetrics = struct { symbol: []const u8, // owned series_name: ?[]const u8 = null, // owned cik: []const u8, // owned /// Null for unit-investment-trust ETFs (SPY, etc.) that file /// NPORT-P at the trust-CIK level without a series identifier. series_id: ?[]const u8 = null, // owned net_assets: ?f64 = null, period_end: ?[]const u8 = null, // owned as_of: []const u8, // owned (date scraper ran) holdings: []Holding, // owned sectors: []SectorWeight, // owned pub fn deinit(self: EtfMetrics, allocator: std.mem.Allocator) void { allocator.free(self.symbol); if (self.series_name) |s| allocator.free(s); allocator.free(self.cik); if (self.series_id) |s| allocator.free(s); if (self.period_end) |s| allocator.free(s); allocator.free(self.as_of); for (self.holdings) |*h| { allocator.free(h.name); if (h.ticker) |t| allocator.free(t); if (h.cusip) |c| allocator.free(c); if (h.lei) |l| allocator.free(l); if (h.country) |c| allocator.free(c); } allocator.free(self.holdings); for (self.sectors) |*s| { allocator.free(s.code); allocator.free(s.description); } allocator.free(self.sectors); } }; /// SRF-emit shape for the SEC's `company_tickers_mf.json` document. /// One row per (symbol, series, class). The cache file lives under /// `/_edgar/tickers_funds.srf`. `MutualFundTickerEntry` /// and `CompanyTickerEntry` are structurally identical but exist as /// distinct types because `Store.dataTypeFor(T)` keys on Zig type; /// distinct types map to distinct on-disk caches. pub const MutualFundTickerEntry = TickerEntry(MutualFund); const MutualFund = struct {}; fn TickerEntry(comptime T: type) type { return struct { /// Ticker symbol - e.g. "VTI", "AGG". The hashmap key when the /// caller builds a `TickerMap` for fast lookup. symbol: []const u8, /// Filer CIK, zero-padded to 10 digits. cik: []const u8, /// SEC series identifier (e.g. "S000002848"). Always present /// for entries from `company_tickers_mf.json`. series_id: ?[]const u8 = null, /// SEC class identifier (e.g. "C000007808"). Always present /// for entries from `company_tickers_mf.json`. class_id: ?[]const u8 = null, /// Trust / company name. Not populated by the MF ticker map /// (it carries no human-readable name); kept here for shape /// parity with `CompanyTickerEntry`. title: ?[]const u8 = null, const Child = T; const Self = @This(); pub fn deinit(self: Self, allocator: std.mem.Allocator) void { allocator.free(self.symbol); allocator.free(self.cik); if (self.series_id) |s| allocator.free(s); if (self.class_id) |s| allocator.free(s); if (self.title) |s| allocator.free(s); } pub fn freeSlice(allocator: std.mem.Allocator, entries: []const Self) void { for (entries) |e| e.deinit(allocator); allocator.free(entries); } pub fn toGeneric(self: Self) GenericTickerEntry { // SAFETY: setting all fields in for loop var ge: GenericTickerEntry = undefined; inline for (@typeInfo(Self).@"struct".fields) |f| @field(ge, f.name) = @field(self, f.name); return ge; } }; } pub const CompanyTickerEntry = TickerEntry(CompanyTicker); const CompanyTicker = struct {}; const GenericTickerEntry = TickerEntry(Ticker); const Ticker = struct {}; /// Fast-lookup wrapper around a slice of ticker entries. Built by /// `fromEntries` after a fetch or cache read; takes ownership of /// the slice. The hashmap stores `*const EntryT` pointers into the /// owned slice - no string duping. `deinit` frees each entry's /// owned strings, the slice itself, and the hashmap structure. /// /// Generic over `EntryT` (currently `MutualFundTickerEntry` or /// `CompanyTickerEntry`) so the same logic serves both ticker /// maps. Callers instantiate as /// `Edgar.TickerMap(MutualFundTickerEntry)` etc. pub fn TickerMap(comptime T: type) type { return struct { entries: []T, map: std.StringHashMap(*const T), allocator: std.mem.Allocator, const Self = @This(); /// Build a TickerMap from a slice of entries. Takes /// ownership of `entries` - caller must NOT free the /// slice; `deinit` will. The hashmap stores pointers into /// the owned slice keyed by `entry.symbol`. pub fn fromEntries(allocator: std.mem.Allocator, entries: []T) !Self { var map: std.StringHashMap(*const T) = .init(allocator); errdefer map.deinit(); try map.ensureTotalCapacity(@intCast(entries.len)); for (entries) |*e| { // First-wins on duplicate symbols. Same rule the // old hashmap-during-parse code applied. const gop = map.getOrPutAssumeCapacity(e.symbol); if (!gop.found_existing) gop.value_ptr.* = e; } return .{ .entries = entries, .map = map, .allocator = allocator, }; } pub fn get(self: Self, symbol: []const u8) ?*const T { return self.map.get(symbol); } pub fn deinit(self: *Self) void { self.map.deinit(); T.freeSlice(self.allocator, self.entries); } }; } /// Parse the SEC's `company_tickers_mf.json` shape into a slice of /// `MutualFundTickerEntry`. Caller owns the slice - free via /// `MutualFundTickerEntry.freeSlice` (or hand to `TickerMap`). pub fn parseTickerMap(allocator: std.mem.Allocator, json_bytes: []const u8) ![]MutualFundTickerEntry { var out: std.ArrayList(MutualFundTickerEntry) = .empty; errdefer { for (out.items) |e| e.deinit(allocator); out.deinit(allocator); } const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => return error.InvalidTickerMap, }; const data_array = switch (root.get("data") orelse return error.InvalidTickerMap) { .array => |a| a.items, else => return error.InvalidTickerMap, }; // Track first-seen symbols so duplicates skip cleanly without // also constructing a hashmap that we'd just throw away. var seen: std.StringHashMap(void) = .init(allocator); defer seen.deinit(); for (data_array) |row| { const fields = switch (row) { .array => |a| a.items, else => continue, }; if (fields.len < 4) continue; const cik_n = switch (fields[0]) { .integer => |n| n, else => continue, }; const series_id = switch (fields[1]) { .string => |s| s, else => continue, }; const class_id = switch (fields[2]) { .string => |s| s, else => continue, }; const symbol = switch (fields[3]) { .string => |s| s, else => continue, }; if (seen.contains(symbol)) continue; // CIKs are normalized to 10-digit zero-padded strings at // every boundary. Wikidata's P5531 uses this convention, so // downstream merge logic can join on the same key shape. // EDGAR ticker-map JSON delivers them as bare integers, so // we pad here. Cast to u64 first because signed `{d:0>10}` // reserves a slot for the sign character and produces // "0000+36405". const cik_str = try std.fmt.allocPrint(allocator, "{d:0>10}", .{@as(u64, @intCast(cik_n))}); errdefer allocator.free(cik_str); const symbol_owned = try allocator.dupe(u8, symbol); errdefer allocator.free(symbol_owned); const series_owned = try allocator.dupe(u8, series_id); errdefer allocator.free(series_owned); const class_owned = try allocator.dupe(u8, class_id); errdefer allocator.free(class_owned); try seen.put(symbol_owned, {}); try out.append(allocator, .{ .symbol = symbol_owned, .cik = cik_str, .series_id = series_owned, .class_id = class_owned, .title = null, }); } return out.toOwnedSlice(allocator); } /// Parser for the stocks-and-UITs `company_tickers.json` shape, which /// is keyed by integer-string indices rather than the array-of-arrays /// shape used by `company_tickers_mf.json`. Each entry has /// `cik_str`, `ticker`, `title`. Returns a slice of /// `CompanyTickerEntry`; caller owns via `CompanyTickerEntry.freeSlice` /// (or hands to `TickerMap`). pub fn parseStockTickerMap(allocator: std.mem.Allocator, json_bytes: []const u8) ![]CompanyTickerEntry { var out: std.ArrayList(CompanyTickerEntry) = .empty; errdefer { for (out.items) |e| e.deinit(allocator); out.deinit(allocator); } const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => return error.InvalidTickerMap, }; var seen: std.StringHashMap(void) = .init(allocator); defer seen.deinit(); var it = root.iterator(); while (it.next()) |entry| { const obj = switch (entry.value_ptr.*) { .object => |o| o, else => continue, }; const cik_n = switch (obj.get("cik_str") orelse continue) { .integer => |n| n, else => continue, }; const symbol = switch (obj.get("ticker") orelse continue) { .string => |s| s, else => continue, }; const title = if (obj.get("title")) |v| switch (v) { .string => |s| s, else => null, } else null; if (seen.contains(symbol)) continue; const cik_str = try std.fmt.allocPrint(allocator, "{d:0>10}", .{@as(u64, @intCast(cik_n))}); errdefer allocator.free(cik_str); const symbol_owned = try allocator.dupe(u8, symbol); errdefer allocator.free(symbol_owned); const title_owned = if (title) |t| try allocator.dupe(u8, t) else null; errdefer if (title_owned) |t| allocator.free(t); try seen.put(symbol_owned, {}); try out.append(allocator, .{ .symbol = symbol_owned, .cik = cik_str, .series_id = null, .class_id = null, .title = title_owned, }); } return out.toOwnedSlice(allocator); } /// Lightweight summary of a CIK's `submissions/CIK*.json` feed. /// Pulls out the four fields callers need (entity_name, entity_type, /// sic_description, latest_nport_p_url) so they can branch without /// re-parsing the full JSON. All owned strings allocated by the /// caller's allocator; caller must free via `deinit`. pub const SubmissionsSummary = struct { entity_name: ?[]const u8 = null, entity_type: ?[]const u8 = null, sic_description: ?[]const u8 = null, /// URL to the most-recent NPORT-P primary_doc.xml, if any. latest_nport_p_url: ?[]const u8 = null, pub fn deinit(self: SubmissionsSummary, allocator: std.mem.Allocator) void { if (self.entity_name) |s| allocator.free(s); if (self.entity_type) |s| allocator.free(s); if (self.sic_description) |s| allocator.free(s); if (self.latest_nport_p_url) |s| allocator.free(s); } }; fn parseSubmissionsFeed( allocator: std.mem.Allocator, json_bytes: []const u8, cik: []const u8, ) !SubmissionsSummary { var out: SubmissionsSummary = .{}; errdefer out.deinit(allocator); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => return out, }; if (root.get("name")) |v| switch (v) { .string => |s| out.entity_name = try allocator.dupe(u8, s), else => {}, }; if (root.get("entityType")) |v| switch (v) { .string => |s| out.entity_type = try allocator.dupe(u8, s), else => {}, }; if (root.get("sicDescription")) |v| switch (v) { .string => |s| if (s.len > 0) { out.sic_description = try allocator.dupe(u8, s); }, else => {}, }; out.latest_nport_p_url = try findNportPUrlInSubmissions(allocator, root, cik); return out; } /// Shares-outstanding from EDGAR XBRL companyconcept endpoint. /// Sourced from the `dei:EntityCommonStockSharesOutstanding` concept, /// which the SEC's Document and Entity Information taxonomy mandates /// on the cover page of 10-K, 10-Q, 40-F, and similar forms. /// /// The dei concept is preferred over `us-gaap:CommonStockSharesOutstanding` /// because it covers Canadian 40-F filers (e.g. Barrick Mining) that /// don't file under us-gaap. EU 20-F filers (e.g. BP) are still NOT /// covered - they use pure ifrs-full without dei tagging - so callers /// must tolerate `null` returns. /// /// `value` is the share count from the most recent reporting period. /// `period_end` is the `end` date that count was reported as-of, in /// `YYYY-MM-DD` form. `form` is the SEC form name (`10-K`, `10-Q`, /// `40-F`, etc.) that supplied the number, useful for staleness /// reasoning ("a 10-Q is 3 months stale, a 40-F is 12 months stale"). pub const SharesOutstanding = struct { value: u64, period_end: []const u8, // owned form: []const u8, // owned pub fn deinit(self: SharesOutstanding, allocator: std.mem.Allocator) void { allocator.free(self.period_end); allocator.free(self.form); } }; /// Per-symbol shares-outstanding record, ready for SRF emission. Joins /// the bare `SharesOutstanding` fetch result (CIK-level) with caller- /// supplied `symbol` and `as_of` so each output row carries the full /// provenance needed by downstream merge logic. /// /// The `source` field has no default - provenance is always emitted /// (per the project's source-pure invariant: every row in a shared /// classification file must self-identify which source produced it). pub const SharesRecord = struct { symbol: []const u8, // owned shares_outstanding: u64, period_end: []const u8, // owned, YYYY-MM-DD form: ?[]const u8 = null, // owned (e.g. "10-Q", "40-F") cik: []const u8, // owned as_of: []const u8, // owned (date scraper ran) source: []const u8, // no default pub fn deinit(self: SharesRecord, allocator: std.mem.Allocator) void { allocator.free(self.symbol); allocator.free(self.period_end); if (self.form) |f| allocator.free(f); allocator.free(self.cik); allocator.free(self.as_of); allocator.free(self.source); } }; /// Tagged union of XBRL-derived per-entity facts. Stored in the /// per-CIK `entity_facts.srf` cache file. Currently only carries /// `shares_outstanding`; new variants (revenue, net income, EPS, /// etc.) get added here as new methods on `Edgar` extract them. /// SRF's default `type` discriminator is what we want, so no /// `srf_tag_field` override is declared. pub const EntityFactRecord = union(enum) { shares_outstanding: SharesRecord, pub fn deinit(self: EntityFactRecord, allocator: std.mem.Allocator) void { switch (self) { .shares_outstanding => |r| r.deinit(allocator), } } /// Free a slice of records, calling deinit on each element first. pub fn freeSlice(allocator: std.mem.Allocator, recs: []const EntityFactRecord) void { for (recs) |r| r.deinit(allocator); allocator.free(recs); } }; /// SRF-emit shape for the `profile` variant of an ETF metrics record. /// One per fund. Disjoint from the internal `EtfMetrics` struct, which /// holds the whole fund's data (profile + N sectors + M holdings) in /// nested arrays for parsing convenience. pub const EtfProfileRecord = struct { symbol: []const u8, // owned series_name: ?[]const u8 = null, // owned cik: []const u8, // owned series_id: ?[]const u8 = null, // owned net_assets: ?f64 = null, period_end: ?[]const u8 = null, // owned, YYYY-MM-DD as_of: []const u8, // owned source: []const u8, // no default pub fn deinit(self: EtfProfileRecord, allocator: std.mem.Allocator) void { allocator.free(self.symbol); if (self.series_name) |s| allocator.free(s); allocator.free(self.cik); if (self.series_id) |s| allocator.free(s); if (self.period_end) |s| allocator.free(s); allocator.free(self.as_of); allocator.free(self.source); } }; /// SRF-emit shape for the `sector` variant. One per (assetCat, /// issuerCat) pair within a fund. The `code` field is the raw /// NPORT-P abbreviation; `description` is the human-readable /// translation per `sectorDescription`. pub const EtfSectorRecord = struct { symbol: []const u8, // owned code: []const u8, // owned, e.g. "EC/CORP" description: []const u8, // owned, e.g. "Equity / Corporate" pct_of_portfolio: f64, as_of: []const u8, // owned source: []const u8, // no default pub fn deinit(self: EtfSectorRecord, allocator: std.mem.Allocator) void { allocator.free(self.symbol); allocator.free(self.code); allocator.free(self.description); allocator.free(self.as_of); allocator.free(self.source); } }; /// SRF-emit shape for the `holding` variant. One per top-N holding /// retained from NPORT-P. Carries the full identifier inventory so /// downstream display can prefer ticker > cusip > lei without /// refetching. pub const EtfHoldingRecord = struct { symbol: []const u8, // owned; the FUND's symbol name: []const u8, // owned; holding's company / instrument name ticker: ?[]const u8 = null, // owned cusip: ?[]const u8 = null, // owned lei: ?[]const u8 = null, // owned country: ?[]const u8 = null, // owned, ISO-3166 alpha-2 pct_of_portfolio: f64, as_of: []const u8, // owned source: []const u8, // no default pub fn deinit(self: EtfHoldingRecord, allocator: std.mem.Allocator) void { allocator.free(self.symbol); allocator.free(self.name); if (self.ticker) |s| allocator.free(s); if (self.cusip) |s| allocator.free(s); if (self.lei) |s| allocator.free(s); if (self.country) |s| allocator.free(s); allocator.free(self.as_of); allocator.free(self.source); } }; /// Tagged union covering all three rows of `etf_metrics.srf`. SRF's /// default `type` discriminator is what we want, so no `srf_tag_field` /// override is declared. Builders (in `main.zig`) construct the slice /// by appending one `.profile` then N `.sector` then M `.holding` /// variants per fund. pub const EtfMetricRecord = union(enum) { profile: EtfProfileRecord, sector: EtfSectorRecord, holding: EtfHoldingRecord, pub fn deinit(self: EtfMetricRecord, allocator: std.mem.Allocator) void { switch (self) { .profile => |r| r.deinit(allocator), .sector => |r| r.deinit(allocator), .holding => |r| r.deinit(allocator), } } /// Free a slice of records, calling deinit on each element first. pub fn freeSlice(allocator: std.mem.Allocator, recs: []const EtfMetricRecord) void { for (recs) |r| r.deinit(allocator); allocator.free(recs); } }; /// Decompose one fund's internal `EtfMetrics` struct into the SRF- /// emit-shaped union slice. Appends one `.profile` variant then N /// `.sector` variants then M `.holding` variants to `out`. All /// strings on the resulting union values are freshly duped so the /// caller can deinit `metrics` independently. Caller owns the /// appended union values and must deinit them. pub fn appendEtfMetricRecords( allocator: std.mem.Allocator, out: *std.ArrayList(EtfMetricRecord), metrics: EtfMetrics, ) !void { try out.append(allocator, .{ .profile = .{ .symbol = try allocator.dupe(u8, metrics.symbol), .series_name = if (metrics.series_name) |s| try allocator.dupe(u8, s) else null, .cik = try allocator.dupe(u8, metrics.cik), .series_id = if (metrics.series_id) |s| try allocator.dupe(u8, s) else null, .net_assets = metrics.net_assets, .period_end = if (metrics.period_end) |s| try allocator.dupe(u8, s) else null, .as_of = try allocator.dupe(u8, metrics.as_of), .source = try allocator.dupe(u8, "edgar"), } }); for (metrics.sectors) |s| { try out.append(allocator, .{ .sector = .{ .symbol = try allocator.dupe(u8, metrics.symbol), .code = try allocator.dupe(u8, s.code), .description = try allocator.dupe(u8, s.description), .pct_of_portfolio = s.pct_of_portfolio, .as_of = try allocator.dupe(u8, metrics.as_of), .source = try allocator.dupe(u8, "edgar"), } }); } for (metrics.holdings) |h| { try out.append(allocator, .{ .holding = .{ .symbol = try allocator.dupe(u8, metrics.symbol), .name = try allocator.dupe(u8, h.name), .ticker = if (h.ticker) |t| try allocator.dupe(u8, t) else null, .cusip = if (h.cusip) |c| try allocator.dupe(u8, c) else null, .lei = if (h.lei) |l| try allocator.dupe(u8, l) else null, .country = if (h.country) |c| try allocator.dupe(u8, c) else null, .pct_of_portfolio = h.pct_of_portfolio, .as_of = try allocator.dupe(u8, metrics.as_of), .source = try allocator.dupe(u8, "edgar"), } }); } } fn parseSharesOutstanding( allocator: std.mem.Allocator, json_bytes: []const u8, ) !?SharesOutstanding { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => return null, }; const units = switch (root.get("units") orelse return null) { .object => |o| o, else => return null, }; // The unit key is "shares". Defensive: take the first units // entry whose array has at least one row. var rows: []std.json.Value = &.{}; var unit_it = units.iterator(); while (unit_it.next()) |entry| { switch (entry.value_ptr.*) { .array => |a| if (a.items.len > 0) { rows = a.items; break; }, else => continue, } } if (rows.len == 0) return null; // Pick the row with the latest `end` date. EDGAR usually returns // them in chronological order but don't rely on that. var best_idx: usize = 0; var best_end: []const u8 = ""; for (rows, 0..) |row, i| { const obj = switch (row) { .object => |o| o, else => continue, }; const end = switch (obj.get("end") orelse continue) { .string => |s| s, else => continue, }; if (std.mem.order(u8, end, best_end) == .gt) { best_end = end; best_idx = i; } } if (best_end.len == 0) return null; const obj = switch (rows[best_idx]) { .object => |o| o, else => return null, }; const val_node = obj.get("val") orelse return null; const val: u64 = switch (val_node) { .integer => |n| if (n < 0) return null else @intCast(n), .float => |f| if (f < 0) return null else @intFromFloat(f), else => return null, }; const form_str: []const u8 = switch (obj.get("form") orelse .null) { .string => |s| s, else => "", }; return .{ .value = val, .period_end = try allocator.dupe(u8, best_end), .form = try allocator.dupe(u8, form_str), }; } fn findNportPUrlInSubmissions( allocator: std.mem.Allocator, root: std.json.ObjectMap, cik: []const u8, ) !?[]u8 { const filings = switch (root.get("filings") orelse return null) { .object => |o| o, else => return null, }; const recent = switch (filings.get("recent") orelse return null) { .object => |o| o, else => return null, }; const forms = switch (recent.get("form") orelse return null) { .array => |a| a.items, else => return null, }; const accessions = switch (recent.get("accessionNumber") orelse return null) { .array => |a| a.items, else => return null, }; const dates = switch (recent.get("filingDate") orelse return null) { .array => |a| a.items, else => return null, }; var best_idx: ?usize = null; var best_date: []const u8 = ""; for (forms, 0..) |form, i| { const fname = switch (form) { .string => |s| s, else => continue, }; if (!std.mem.eql(u8, fname, "NPORT-P")) continue; if (i >= dates.len) continue; const fd = switch (dates[i]) { .string => |s| s, else => continue, }; if (std.mem.order(u8, fd, best_date) == .gt) { best_date = fd; best_idx = i; } } const idx = best_idx orelse return null; const acc = switch (accessions[idx]) { .string => |s| s, else => return null, }; var cik_no_zeros = cik; while (cik_no_zeros.len > 1 and cik_no_zeros[0] == '0') cik_no_zeros = cik_no_zeros[1..]; var adsh_buf: std.ArrayList(u8) = .empty; defer adsh_buf.deinit(allocator); for (acc) |c| if (c != '-') try adsh_buf.append(allocator, c); return try std.fmt.allocPrint( allocator, "https://www.sec.gov/Archives/edgar/data/{s}/{s}/primary_doc.xml", .{ cik_no_zeros, adsh_buf.items }, ); } /// Extract the most-recent filing URL from an EDGAR full-text search /// response. Used by `findLatestNportP` (series-keyed search). Lifted /// out so the same parser can be reused if we add more search calls. fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const u8) !?[]u8 { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => return null, }; const hits_obj = switch (root.get("hits") orelse return null) { .object => |o| o, else => return null, }; const hits_arr = switch (hits_obj.get("hits") orelse return null) { .array => |a| a.items, else => return null, }; if (hits_arr.len == 0) return null; var best_idx: usize = 0; var best_date: []const u8 = ""; for (hits_arr, 0..) |hit, i| { const hit_obj = switch (hit) { .object => |o| o, else => continue, }; const src = switch (hit_obj.get("_source") orelse continue) { .object => |o| o, else => continue, }; const fd = switch (src.get("file_date") orelse continue) { .string => |s| s, else => continue, }; if (std.mem.order(u8, fd, best_date) == .gt) { best_date = fd; best_idx = i; } } const best = switch (hits_arr[best_idx]) { .object => |o| o, else => return null, }; const src = switch (best.get("_source") orelse return null) { .object => |o| o, else => return null, }; const adsh = switch (src.get("adsh") orelse return null) { .string => |s| s, else => return null, }; const ciks_arr = switch (src.get("ciks") orelse return null) { .array => |a| a.items, else => return null, }; if (ciks_arr.len == 0) return null; const cik_padded = switch (ciks_arr[0]) { .string => |s| s, else => return null, }; var cik_no_zeros = cik_padded; while (cik_no_zeros.len > 1 and cik_no_zeros[0] == '0') cik_no_zeros = cik_no_zeros[1..]; var adsh_buf = std.ArrayList(u8).empty; defer adsh_buf.deinit(allocator); for (adsh) |c| if (c != '-') try adsh_buf.append(allocator, c); return try std.fmt.allocPrint( allocator, "https://www.sec.gov/Archives/edgar/data/{s}/{s}/primary_doc.xml", .{ cik_no_zeros, adsh_buf.items }, ); } /// Classify a CIK based on its submissions-feed metadata. Decides /// whether the symbol is a registered fund (probe NPORT-P), a /// trust/ETN-style instrument (profile-only), or a plain operating /// company (skip). /// /// Decision rules - kept in one place because they're load-bearing /// for what `EtfMetricsResult` variant `fetchEtfMetrics` returns. /// Rules are based on observation across ~100 real symbols: /// /// 1. Has NPORT-P filing -> fund_shaped. /// The presence of a NPORT-P is the unambiguous signal that /// the entity is a registered investment company. Catches all /// ETFs and mutual funds regardless of entityType / SIC. /// /// 2. entityType == "other" AND SIC indicates /// a securities issuer or commodity dealer -> trust_shaped. /// Catches ETN issuers (Credit Suisse AG -> SLVO), commodity /// brokers (some smaller commodity trusts), without a NPORT-P. /// Does NOT catch foreign issuers like BP/Barrick (entityType /// "other" but SIC is industry-specific, not securities-related). /// /// 3. entityType == "operating" AND SIC contains /// "Commodity" -> trust_shaped. /// Catches commodity grantor trusts (GLD, SLV, IAU, GBTC). /// `entityType` is "operating" for these despite their /// trust-like nature - SEC classifies them as commodity- /// contracts brokers because they hold physical commodities. /// /// 4. otherwise -> operating. /// Plain operating companies (AAPL, NFLX, BRK.B, BP, etc.). /// No fund records emitted; Wikidata covers their classification. /// /// Note: REITs (e.g. Realty Income, O) are `operating` + SIC /// "Real Estate Investment Trusts". They are operating companies /// that distribute rental income, not registered investment /// companies. They get bucketed under `operating` - Wikidata is /// the right source for them. fn classifyByEntityType(sub: *const SubmissionsSummary) enum { fund_shaped, trust_shaped, operating, } { // Rule 1: NPORT-P presence is the strongest fund signal. if (sub.latest_nport_p_url != null) return .fund_shaped; const et = sub.entity_type orelse return .operating; const sic_opt = sub.sic_description; // Rule 2: securities issuers (ETN sponsor banks). if (std.mem.eql(u8, et, "other")) { if (sic_opt) |sic| { const securities_hints = [_][]const u8{ "Security Brokers", // "Security Brokers, Dealers..." "Commodity Contracts", "Investment Trust", // explicit, not "Real Estate Investment Trusts" }; for (securities_hints) |h| { if (std.mem.indexOf(u8, sic, h) != null) return .trust_shaped; } } return .operating; } // Rule 3: commodity grantor trusts classified as "operating". if (std.mem.eql(u8, et, "operating")) { if (sic_opt) |sic| { if (std.mem.indexOf(u8, sic, "Commodity") != null) { return .trust_shaped; } } } return .operating; } test "classifyByEntityType buckets real-world entities" { const T = std.testing; // SPY: NPORT-P present -> fund_shaped (regardless of other fields). { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "other"); s.latest_nport_p_url = try T.allocator.dupe(u8, "https://example/primary_doc.xml"); try T.expectEqual(.fund_shaped, classifyByEntityType(&s)); } // SLVO/GLDI/USOI issuer (Credit Suisse AG): no NPORT-P, "other" // entityType, SIC = "Security Brokers..." -> trust_shaped. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "other"); s.sic_description = try T.allocator.dupe(u8, "Security Brokers, Dealers & Flotation Companies"); try T.expectEqual(.trust_shaped, classifyByEntityType(&s)); } // BP plc: foreign issuer, "other" entityType, SIC = industry. // Should be `operating`, not trust_shaped. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "other"); s.sic_description = try T.allocator.dupe(u8, "Petroleum Refining"); try T.expectEqual(.operating, classifyByEntityType(&s)); } // Barrick: same shape as BP. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "other"); s.sic_description = try T.allocator.dupe(u8, "Gold and Silver Ores"); try T.expectEqual(.operating, classifyByEntityType(&s)); } // GLD: "operating" entityType but SIC is commodity-contracts. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "operating"); s.sic_description = try T.allocator.dupe(u8, "Commodity Contracts Brokers & Dealers"); try T.expectEqual(.trust_shaped, classifyByEntityType(&s)); } // AAPL. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "operating"); s.sic_description = try T.allocator.dupe(u8, "Electronic Computers"); try T.expectEqual(.operating, classifyByEntityType(&s)); } // NFLX. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "operating"); s.sic_description = try T.allocator.dupe(u8, "Services-Video Tape Rental"); try T.expectEqual(.operating, classifyByEntityType(&s)); } // Realty Income (O): REIT, operating company. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); s.entity_type = try T.allocator.dupe(u8, "operating"); s.sic_description = try T.allocator.dupe(u8, "Real Estate Investment Trusts"); try T.expectEqual(.operating, classifyByEntityType(&s)); } } /// Result kind for `fetchEtfMetrics`. The caller - see `main.zig` - /// distinguishes a full holdings record from a profile-only record so /// it can log the right thing and produce accurate coverage stats. pub const EtfMetricsResult = union(enum) { /// Full NPORT-P parse with holdings + sectors. full: EtfMetrics, /// Submissions-feed metadata only. Used for unit-investment trusts /// that file 10-K instead of NPORT-P (commodity trusts like GLD, /// some grantor trusts). profile_only: EtfMetrics, /// Symbol is in the stock-ticker map but is a plain operating /// company (AAPL, MSFT, ...). Not a fund. Caller should skip. not_a_fund: void, /// Symbol isn't in either ticker map. Caller should skip. not_in_edgar: void, }; /// Construct an EtfMetrics record from submissions-feed metadata /// alone, with no holdings or sectors. Used for trust entities (e.g. /// commodity trusts) that lack a NPORT-P filing but for which we /// still want to surface name + CIK in `etf_metrics.srf`. /// /// Generic over the entry type (`MutualFundTickerEntry` or /// `CompanyTickerEntry`). fn buildProfileOnlyMetrics( io: std.Io, allocator: std.mem.Allocator, entry: GenericTickerEntry, sub: *const SubmissionsSummary, symbol: []const u8, ) !EtfMetrics { var as_of_buf: [10]u8 = undefined; const today_date = fmt.todayDate(io); const as_of = try std.fmt.bufPrint(&as_of_buf, "{f}", .{today_date}); // Prefer the submissions-feed name (canonical, "SPDR GOLD TRUST") // over the company_tickers.json title (less authoritative). const name_src: ?[]const u8 = sub.entity_name orelse entry.title; const series_name: ?[]u8 = if (name_src) |n| try allocator.dupe(u8, n) else null; errdefer if (series_name) |s| allocator.free(s); return .{ .symbol = try allocator.dupe(u8, symbol), .series_name = series_name, .cik = try allocator.dupe(u8, entry.cik), .series_id = null, .net_assets = null, .period_end = null, .as_of = try allocator.dupe(u8, as_of), .holdings = &.{}, .sectors = &.{}, }; } /// Parse N-PORT-P bytes into an EtfMetrics struct. Heavy XML - we use /// the vendored `xml.zig` DOM parser. /// /// `entry` is a `GenericTickerEntry` - `MutualFundTickerEntry` /// and `CompanyTickerEntry` callers use `.toGeneric()` to collapse /// to this shape, so `parseNportP` doesn't need to know which /// on-disk cache the entry came from. fn parseNportP( io: std.Io, allocator: std.mem.Allocator, xml_bytes: []const u8, symbol: []const u8, entry: GenericTickerEntry, top_n_holdings: usize, ) !EtfMetrics { var as_of_buf: [10]u8 = undefined; const today_date = fmt.todayDate(io); const as_of = try std.fmt.bufPrint(&as_of_buf, "{f}", .{today_date}); var doc = try xml.parse(allocator, xml_bytes); defer doc.deinit(); const root = doc.root; // Walk: edgarSubmission > formData > genInfo and fundInfo. const form_data = (try root.findChildByTag("formData")) orelse return error.MissingFormData; const gen_info = try form_data.findChildByTag("genInfo"); const fund_info = try form_data.findChildByTag("fundInfo"); const invst_or_secs = try form_data.findChildByTag("invstOrSecs"); var series_name: ?[]const u8 = null; var period_end: ?[]const u8 = null; if (gen_info) |gi| { if (try gi.findChildByTag("seriesName")) |e| { if (e.children.items.len > 0) { if (e.children.items[0] == .CharData) { const sn = e.children.items[0].CharData; // Single-series trusts (SPY, IVV, ...) write // "N/A" here - drop it so we fall through to the // ticker-map title below. if (!std.mem.eql(u8, sn, "N/A") and sn.len > 0) { series_name = try allocator.dupe(u8, sn); } } } } } // Fall back to the ticker-map title (e.g. "SPDR S&P 500 ETF Trust" // for SPY) if NPORT-P didn't supply a useful series name. The // title comes from `company_tickers.json` for stock-map entries. if (series_name == null) { if (entry.title) |t| { series_name = try allocator.dupe(u8, t); } } if (gen_info) |gi| { if (try gi.findChildByTag("repPdEnd")) |e| { if (e.children.items.len > 0) { if (e.children.items[0] == .CharData) { period_end = try allocator.dupe(u8, e.children.items[0].CharData); } } } } var net_assets: ?f64 = null; if (fund_info) |fi| { if (try fi.findChildByTag("netAssets")) |e| { if (e.children.items.len > 0) { if (e.children.items[0] == .CharData) { net_assets = std.fmt.parseFloat(f64, e.children.items[0].CharData) catch null; } } } } // Holdings + sector breakdown. var holdings_list: std.ArrayList(Holding) = .empty; errdefer { for (holdings_list.items) |h| { allocator.free(h.name); if (h.ticker) |t| allocator.free(t); if (h.cusip) |c| allocator.free(c); if (h.lei) |l| allocator.free(l); if (h.country) |c| allocator.free(c); } holdings_list.deinit(allocator); } // Sector aggregation: assetCat × issuerCat -> cumulative weight var sector_map: std.StringHashMap(f64) = .init(allocator); defer { var it = sector_map.iterator(); while (it.next()) |entry2| allocator.free(entry2.key_ptr.*); sector_map.deinit(); } if (invst_or_secs) |secs| { for (secs.children.items) |child| { if (child != .Element) continue; const sec = child.Element; if (!std.mem.eql(u8, sec.tag, "invstOrSec")) continue; const name_text = elementText(sec, "name") orelse continue; const pct_text = elementText(sec, "pctVal") orelse continue; const pct = std.fmt.parseFloat(f64, pct_text) catch continue; try holdings_list.append(allocator, .{ .name = try allocator.dupe(u8, name_text), .ticker = if (elementAttrValue(sec, "identifiers", "ticker")) |t| try allocator.dupe(u8, t) else null, .cusip = if (elementText(sec, "cusip")) |c| try allocator.dupe(u8, c) else null, .lei = if (elementTextOptional(sec, "lei")) |l| try allocator.dupe(u8, l) else null, .country = if (elementTextOptional(sec, "invCountry")) |c| try allocator.dupe(u8, c) else null, .pct_of_portfolio = pct, }); // Aggregate by (assetCat, issuerCat). const asset_cat = elementText(sec, "assetCat") orelse "?"; const issuer_cat = elementText(sec, "issuerCat") orelse "?"; const key = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ asset_cat, issuer_cat }); const gop = try sector_map.getOrPut(key); if (gop.found_existing) { allocator.free(key); gop.value_ptr.* += pct; } else { gop.value_ptr.* = pct; } } } // Top N holdings by pct_of_portfolio. const all_holdings = try holdings_list.toOwnedSlice(allocator); std.mem.sort(Holding, all_holdings, {}, struct { fn gt(_: void, a: Holding, b: Holding) bool { return a.pct_of_portfolio > b.pct_of_portfolio; } }.gt); const keep = @min(all_holdings.len, top_n_holdings); const top = try allocator.alloc(Holding, keep); for (all_holdings[0..keep], 0..) |h, i| top[i] = h; // Free the rest. for (all_holdings[keep..]) |h| { allocator.free(h.name); if (h.ticker) |t| allocator.free(t); if (h.cusip) |c| allocator.free(c); if (h.lei) |l| allocator.free(l); if (h.country) |c| allocator.free(c); } allocator.free(all_holdings); // Sector list. var sectors_list: std.ArrayList(SectorWeight) = .empty; errdefer sectors_list.deinit(allocator); var s_it = sector_map.iterator(); while (s_it.next()) |s_entry| { const code = s_entry.key_ptr.*; try sectors_list.append(allocator, .{ .code = try allocator.dupe(u8, code), .description = try allocator.dupe(u8, sectorDescription(code)), .pct_of_portfolio = s_entry.value_ptr.*, }); } const sectors = try sectors_list.toOwnedSlice(allocator); std.mem.sort(SectorWeight, sectors, {}, struct { fn gt(_: void, a: SectorWeight, b: SectorWeight) bool { return a.pct_of_portfolio > b.pct_of_portfolio; } }.gt); return .{ .symbol = try allocator.dupe(u8, symbol), .series_name = series_name, .cik = try allocator.dupe(u8, entry.cik), .series_id = if (entry.series_id) |sid| try allocator.dupe(u8, sid) else null, .net_assets = net_assets, .period_end = period_end, .as_of = try allocator.dupe(u8, as_of), .holdings = top, .sectors = sectors, }; } /// Translation table for NPORT-P `assetCat/issuerCat` codes. The /// values are the SEC's own form-instruction abbreviations; the /// descriptions are condensed-but-accurate human readings used to /// populate `SectorWeight.description`. /// /// Coverage targets the codes observed across a representative /// real-world portfolio (~32 distinct codes seen across stock / /// bond / blended ETFs). Unrecognized codes round-trip raw (the /// lookup falls back to the code itself) so unknowns surface for /// table extension rather than silently corrupting downstream /// classification. /// /// AssetCat values per SEC form instructions: /// EC Equity (common) DE Derivative /// EP Equity Preferred DFE Derivative - Foreign Exchange /// DBT Debt DIR Direct Investment in Real Property /// ABS-MBS Asset-Backed Mortgage DCR Direct Credit Risk /// ABS-O Asset-Backed Other LON Loan /// ABS-CBDO Asset-Backed CBO/CDO STIV Short-Term Investment Vehicle /// RA Repurchase Agreement ? Other / Unknown /// /// IssuerCat values per SEC form instructions: /// CORP Corporate MUN Municipal /// UST US Treasury NUSS Non-US Sovereign /// USGA US Government Agency RF Registered Fund /// USGSE US Government-Sponsored Enterprise /// PF Private Fund ? Other / Unknown const sector_code_descriptions = [_]struct { code: []const u8, description: []const u8, }{ // Equity (common) .{ .code = "EC/CORP", .description = "Equity / Corporate" }, .{ .code = "EC/RF", .description = "Equity / Registered Fund" }, .{ .code = "EC/NUSS", .description = "Equity / Non-US Sovereign" }, .{ .code = "EC/?", .description = "Equity / Other" }, // Equity preferred .{ .code = "EP/CORP", .description = "Equity Preferred / Corporate" }, .{ .code = "EP/NUSS", .description = "Equity Preferred / Non-US Sovereign" }, // Debt .{ .code = "DBT/UST", .description = "Debt / US Treasury" }, .{ .code = "DBT/USGA", .description = "Debt / US Gov Agency" }, .{ .code = "DBT/USGSE", .description = "Debt / US GSE" }, .{ .code = "DBT/CORP", .description = "Debt / Corporate" }, .{ .code = "DBT/MUN", .description = "Debt / Municipal" }, .{ .code = "DBT/NUSS", .description = "Debt / Non-US Sovereign" }, .{ .code = "DBT/?", .description = "Debt / Other" }, // Asset-backed .{ .code = "ABS-MBS/USGSE", .description = "Asset-Backed / US GSE Mortgage" }, .{ .code = "ABS-MBS/USGA", .description = "Asset-Backed / US Gov Agency Mortgage" }, .{ .code = "ABS-MBS/CORP", .description = "Asset-Backed / Corporate Mortgage" }, .{ .code = "ABS-O/CORP", .description = "Asset-Backed Other / Corporate" }, .{ .code = "ABS-CBDO/CORP", .description = "Asset-Backed CBO/CDO / Corporate" }, // Repurchase agreements .{ .code = "RA/CORP", .description = "Repurchase Agreement / Corporate" }, .{ .code = "RA/?", .description = "Repurchase Agreement / Other" }, // Loans .{ .code = "LON/CORP", .description = "Loan / Corporate" }, // Short-term investment vehicles .{ .code = "STIV/CORP", .description = "Short-Term Investment Vehicle / Corporate" }, .{ .code = "STIV/RF", .description = "Short-Term Investment Vehicle / Registered Fund" }, .{ .code = "STIV/PF", .description = "Short-Term Investment Vehicle / Private Fund" }, // Derivatives .{ .code = "DE/CORP", .description = "Derivative / Corporate" }, .{ .code = "DE/?", .description = "Derivative / Other" }, .{ .code = "DFE/CORP", .description = "Derivative-FX / Corporate" }, .{ .code = "DFE/?", .description = "Derivative-FX / Other" }, // Direct investment / direct credit risk .{ .code = "DIR/?", .description = "Direct Real Property / Other" }, .{ .code = "DCR/?", .description = "Direct Credit Risk / Other" }, // Catch-all unknowns. We translate "?/X" to a more readable // shape but preserve the structure (issuer is known even if // asset class isn't). .{ .code = "?/CORP", .description = "Other / Corporate" }, .{ .code = "?/?", .description = "Other / Other" }, }; /// Look up an NPORT-P sector code's human-readable description. For /// unknown codes returns the code itself, so the caller can always /// `dupe` the result without conditional handling. pub fn sectorDescription(code: []const u8) []const u8 { for (sector_code_descriptions) |entry| { if (std.mem.eql(u8, entry.code, code)) return entry.description; } return code; } fn elementText(parent: *xml.Element, tag: []const u8) ?[]const u8 { const child = (parent.findChildByTag(tag) catch return null) orelse return null; if (child.children.items.len == 0) return null; if (child.children.items[0] != .CharData) return null; return child.children.items[0].CharData; } /// Read the `value` attribute of a child element identified by `tag`, /// optionally nested inside `parent_tag` (use `null` for direct /// children). Returns null when either path step fails. Used to pull /// `` out of NPORT-P /// holding records, where the ticker is encoded as an attribute on a /// nested element rather than as text content. fn elementAttrValue(parent: *xml.Element, parent_tag: ?[]const u8, tag: []const u8) ?[]const u8 { const container: *xml.Element = if (parent_tag) |pt| (parent.findChildByTag(pt) catch return null) orelse return null else parent; const child = (container.findChildByTag(tag) catch return null) orelse return null; return child.getAttribute("value"); } /// Like `elementText` but treats NPORT-P's `"N/A"` sentinel and the /// empty string as absent. NPORT-P uses literal `"N/A"` for missing /// LEIs on issuers without one, and empty `` for /// holdings whose country can't be determined; both should round-trip /// as null in Zig. fn elementTextOptional(parent: *xml.Element, tag: []const u8) ?[]const u8 { const text = elementText(parent, tag) orelse return null; if (text.len == 0) return null; if (std.mem.eql(u8, text, "N/A")) return null; return text; } test "parseTickerMap parses fixture rows" { const fixture = \\{"fields":["cik","seriesId","classId","symbol"],"data":[ \\ [36405,"S000002848","C000007808","VTI"], \\ [1100663,"S000004362","C000012092","AGG"] \\]} ; const allocator = std.testing.allocator; const entries = try parseTickerMap(allocator, fixture); var map = try TickerMap(MutualFundTickerEntry).fromEntries(allocator, entries); defer map.deinit(); const vti = map.get("VTI") orelse return error.TestFailed; try std.testing.expectEqualStrings("VTI", vti.symbol); try std.testing.expectEqualStrings("0000036405", vti.cik); try std.testing.expectEqualStrings("S000002848", vti.series_id orelse return error.TestFailed); const agg = map.get("AGG") orelse return error.TestFailed; try std.testing.expectEqualStrings("0001100663", agg.cik); } test "parseStockTickerMap parses fixture" { const fixture = \\{ \\ "0":{"cik_str":78462,"ticker":"SPY","title":"SPDR S&P 500 ETF Trust"}, \\ "1":{"cik_str":1222333,"ticker":"GLD","title":"SPDR GOLD TRUST"} \\} ; const allocator = std.testing.allocator; const entries = try parseStockTickerMap(allocator, fixture); var map = try TickerMap(CompanyTickerEntry).fromEntries(allocator, entries); defer map.deinit(); const spy = map.get("SPY") orelse return error.TestFailed; try std.testing.expectEqualStrings("SPY", spy.symbol); try std.testing.expectEqualStrings("0000078462", spy.cik); try std.testing.expect(spy.series_id == null); try std.testing.expectEqualStrings("SPDR S&P 500 ETF Trust", spy.title orelse return error.TestFailed); const gld = map.get("GLD") orelse return error.TestFailed; try std.testing.expectEqualStrings("0001222333", gld.cik); } test "sectorDescription translates known codes and round-trips unknown" { // Known codes get translated. try std.testing.expectEqualStrings("Equity / Corporate", sectorDescription("EC/CORP")); try std.testing.expectEqualStrings("Debt / US Treasury", sectorDescription("DBT/UST")); try std.testing.expectEqualStrings("Asset-Backed / US GSE Mortgage", sectorDescription("ABS-MBS/USGSE")); // Codes added to cover real-world NPORT-P output. try std.testing.expectEqualStrings("Debt / Municipal", sectorDescription("DBT/MUN")); try std.testing.expectEqualStrings("Short-Term Investment Vehicle / Registered Fund", sectorDescription("STIV/RF")); try std.testing.expectEqualStrings("Repurchase Agreement / Corporate", sectorDescription("RA/CORP")); try std.testing.expectEqualStrings("Other / Other", sectorDescription("?/?")); // Unknown codes round-trip raw so future additions surface for // table extension rather than getting silently mistranslated. try std.testing.expectEqualStrings("MADE/UPCODE", sectorDescription("MADE/UPCODE")); try std.testing.expectEqualStrings("", sectorDescription("")); } // ── buildProfileOnlyMetrics ──────────────────────────────────── test "buildProfileOnlyMetrics: prefers submissions entity_name over entry.title" { const allocator = std.testing.allocator; const sub = SubmissionsSummary{ .entity_name = "SPDR GOLD TRUST", }; const entry = GenericTickerEntry{ .symbol = "GLD", .cik = "0001222333", .title = "less authoritative title", }; var metrics = try buildProfileOnlyMetrics(std.testing.io, allocator, entry, &sub, "GLD"); defer metrics.deinit(allocator); try std.testing.expectEqualStrings("SPDR GOLD TRUST", metrics.series_name orelse return error.SeriesNameMissing); try std.testing.expectEqualStrings("GLD", metrics.symbol); try std.testing.expectEqualStrings("0001222333", metrics.cik); try std.testing.expect(metrics.series_id == null); try std.testing.expect(metrics.net_assets == null); try std.testing.expect(metrics.period_end == null); try std.testing.expectEqual(@as(usize, 0), metrics.holdings.len); try std.testing.expectEqual(@as(usize, 0), metrics.sectors.len); } test "buildProfileOnlyMetrics: falls back to entry.title when submissions has no name" { const allocator = std.testing.allocator; const sub = SubmissionsSummary{}; // entity_name = null const entry = GenericTickerEntry{ .symbol = "FOO", .cik = "0", .title = "Foo Corp", }; var metrics = try buildProfileOnlyMetrics(std.testing.io, allocator, entry, &sub, "FOO"); defer metrics.deinit(allocator); try std.testing.expectEqualStrings("Foo Corp", metrics.series_name orelse return error.SeriesNameMissing); } test "buildProfileOnlyMetrics: both name sources null -> series_name is null" { const allocator = std.testing.allocator; const sub = SubmissionsSummary{}; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try buildProfileOnlyMetrics(std.testing.io, allocator, entry, &sub, "X"); defer metrics.deinit(allocator); try std.testing.expect(metrics.series_name == null); } test "buildProfileOnlyMetrics: as_of is a 10-char YYYY-MM-DD string" { const allocator = std.testing.allocator; const sub = SubmissionsSummary{}; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try buildProfileOnlyMetrics(std.testing.io, allocator, entry, &sub, "X"); defer metrics.deinit(allocator); try std.testing.expectEqual(@as(usize, 10), metrics.as_of.len); try std.testing.expectEqual(@as(u8, '-'), metrics.as_of[4]); try std.testing.expectEqual(@as(u8, '-'), metrics.as_of[7]); } test "parseNportP holdings: ticker/lei/country populated when present" { const allocator = std.testing.allocator; // Minimal NPORT-P fixture covering the holding-identifier shapes. // Two holdings: first has all identifiers, second is bare-bones // with the "N/A" LEI sentinel and an empty . const xml_fixture = \\ \\ \\ \\ \\ \\ Argan Inc \\ 529900E4KZWBV9KGBS83 \\ 04010E109 \\ \\ \\ \\ \\ 4.89 \\ EC \\ CORP \\ US \\ \\ \\ Mystery Bond \\ N/A \\ 000000000 \\ 0.50 \\ DBT \\ CORP \\ \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "TEST", .cik = "0000000000", }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "TEST", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqual(@as(usize, 2), metrics.holdings.len); // Holdings are sorted by pct descending - Argan first. const argan = metrics.holdings[0]; try std.testing.expectEqualStrings("Argan Inc", argan.name); try std.testing.expectEqualStrings("AGX", argan.ticker orelse return error.TickerMissing); try std.testing.expectEqualStrings("04010E109", argan.cusip orelse return error.CusipMissing); try std.testing.expectEqualStrings("529900E4KZWBV9KGBS83", argan.lei orelse return error.LeiMissing); try std.testing.expectEqualStrings("US", argan.country orelse return error.CountryMissing); // Mystery Bond: no , "N/A" lei, empty . const mystery = metrics.holdings[1]; try std.testing.expectEqualStrings("Mystery Bond", mystery.name); try std.testing.expect(mystery.ticker == null); try std.testing.expect(mystery.lei == null); try std.testing.expect(mystery.country == null); try std.testing.expectEqualStrings("000000000", mystery.cusip orelse return error.CusipMissing); } test "parseNportP: populated -> series_name from XML" { // Multi-series trusts (Vanguard family, Fidelity family) put // the canonical series name in . We // should pick that up over the entry.title fallback. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ Vanguard Total Bond Market Index Fund \\ 2025-09-30 \\ \\ \\ \\ US Treasury 4.5% 2030 \\ 5.0 \\ DBT \\ UST \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "VBTLX", .cik = "0000000000", .title = "Should Be Ignored When XML Has Series Name", }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "VBTLX", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqualStrings( "Vanguard Total Bond Market Index Fund", metrics.series_name orelse return error.SeriesNameMissing, ); try std.testing.expectEqualStrings( "2025-09-30", metrics.period_end orelse return error.PeriodEndMissing, ); } test "parseNportP: seriesName == 'N/A' falls through to entry.title" { // Single-series trusts (SPY, IVV) write "N/A" in // . We must drop it and fall back to the // ticker-map title from `entry`. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ N/A \\ \\ \\ \\ AAPL \\ 7.0 \\ EC \\ CORP \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "SPY", .cik = "0000000000", .title = "SPDR S&P 500 ETF Trust", }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "SPY", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqualStrings( "SPDR S&P 500 ETF Trust", metrics.series_name orelse return error.SeriesNameMissing, ); } test "parseNportP: empty falls through to entry.title" { // Empty-element form (sn.len == 0) is the same fallback // case as N/A - make sure both paths trigger the title // fallback. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ \\ \\ \\ \\ X \\ 1.0 \\ EC \\ CORP \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0", .title = "Fallback Title", }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqualStrings( "Fallback Title", metrics.series_name orelse return error.SeriesNameMissing, ); } test "parseNportP: no , no entry.title -> series_name is null" { const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ \\ X \\ 1.0 \\ EC \\ CORP \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expect(metrics.series_name == null); } test "parseNportP: populated -> net_assets parsed as f64" { const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ 123456789.50 \\ \\ \\ \\ X \\ 1.0 \\ EC \\ CORP \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expect(metrics.net_assets != null); try std.testing.expectApproxEqAbs(@as(f64, 123456789.5), metrics.net_assets.?, 0.01); } test "parseNportP: with garbage text -> net_assets is null (no error)" { // Defensive: we use `catch null` on parseFloat so a malformed // value doesn't blow up the whole parse. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ not-a-number \\ \\ \\ \\ X \\ 1.0 \\ EC \\ CORP \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expect(metrics.net_assets == null); } test "parseNportP: duplicate (assetCat, issuerCat) sums into a single sector" { // The aggregation branch: when two holdings share the same // (assetCat, issuerCat) bucket, their pcts add into one // sector entry rather than producing duplicate rows. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ \\ Holding A \\ 30.0 \\ EC \\ CORP \\ \\ \\ Holding B \\ 20.0 \\ EC \\ CORP \\ \\ \\ Holding C \\ 50.0 \\ DBT \\ UST \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); // Expect 2 distinct sectors despite 3 holdings. try std.testing.expectEqual(@as(usize, 2), metrics.sectors.len); // Find the EC/CORP bucket and verify it sums to 50.0. var ec_corp_pct: ?f64 = null; for (metrics.sectors) |s| { if (std.mem.eql(u8, s.code, "EC/CORP")) ec_corp_pct = s.pct_of_portfolio; } try std.testing.expect(ec_corp_pct != null); try std.testing.expectApproxEqAbs(@as(f64, 50.0), ec_corp_pct.?, 0.001); } test "parseNportP: missing assetCat/issuerCat -> '?' bucket key" { // When a holding lacks one or both category tags, we still // bucket it (under "?") rather than dropping it. Verifies // the `orelse "?"` branches. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ \\ Mystery Holding \\ 10.0 \\ \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), metrics.sectors.len); try std.testing.expectEqualStrings("?/?", metrics.sectors[0].code); } test "parseNportP: top_n_holdings caps the holdings array, frees rest" { // When more holdings than top_n exist, we keep the top N by // pctVal and free the rest. testing.allocator catches any // leak in the discard path. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ A10.0ECCORP \\ B20.0ECCORP \\ C30.0ECCORP \\ D40.0ECCORP \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 2); defer metrics.deinit(allocator); try std.testing.expectEqual(@as(usize, 2), metrics.holdings.len); try std.testing.expectEqualStrings("D", metrics.holdings[0].name); // pct 40.0 try std.testing.expectEqualStrings("C", metrics.holdings[1].name); // pct 30.0 } test "parseNportP: holding with non-parseable pctVal is skipped" { // The `parseFloat ... catch continue` branch. const allocator = std.testing.allocator; const xml_fixture = \\ \\ \\ \\ \\ Badnot-a-numberECCORP \\ Good50.0ECCORP \\ \\ \\ ; const entry = GenericTickerEntry{ .symbol = "X", .cik = "0" }; var metrics = try parseNportP(std.testing.io, allocator, xml_fixture, "X", entry, 10); defer metrics.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), metrics.holdings.len); try std.testing.expectEqualStrings("Good", metrics.holdings[0].name); } test "appendEtfMetricRecords decomposes one fund into profile + sectors + holdings" { const allocator = std.testing.allocator; // Build a minimal EtfMetrics by hand. Strings are owned; deinit // matches what `parseNportP` would do. const sectors = try allocator.alloc(SectorWeight, 2); sectors[0] = .{ .code = try allocator.dupe(u8, "EC/CORP"), .description = try allocator.dupe(u8, "Equity / Corporate"), .pct_of_portfolio = 98.5, }; sectors[1] = .{ .code = try allocator.dupe(u8, "STIV/CORP"), .description = try allocator.dupe(u8, "Short-Term Investment Vehicle / Corporate"), .pct_of_portfolio = 1.5, }; const holdings = try allocator.alloc(Holding, 3); holdings[0] = .{ .name = try allocator.dupe(u8, "Apple Inc"), .ticker = try allocator.dupe(u8, "AAPL"), .cusip = try allocator.dupe(u8, "037833100"), .lei = null, .country = try allocator.dupe(u8, "US"), .pct_of_portfolio = 7.0, }; holdings[1] = .{ .name = try allocator.dupe(u8, "Microsoft Corp"), .ticker = try allocator.dupe(u8, "MSFT"), .cusip = try allocator.dupe(u8, "594918104"), .lei = null, .country = try allocator.dupe(u8, "US"), .pct_of_portfolio = 6.0, }; holdings[2] = .{ .name = try allocator.dupe(u8, "NVIDIA Corp"), .ticker = try allocator.dupe(u8, "NVDA"), .cusip = try allocator.dupe(u8, "67066G104"), .lei = null, .country = try allocator.dupe(u8, "US"), .pct_of_portfolio = 5.0, }; var metrics = EtfMetrics{ .symbol = try allocator.dupe(u8, "VTI"), .series_name = try allocator.dupe(u8, "VANGUARD TOTAL STOCK MARKET INDEX FUND"), .cik = try allocator.dupe(u8, "0000036405"), .series_id = try allocator.dupe(u8, "S000002848"), .net_assets = 2_000_000_000_000.0, .period_end = try allocator.dupe(u8, "2025-12-31"), .as_of = try allocator.dupe(u8, "2026-05-25"), .holdings = holdings, .sectors = sectors, }; defer metrics.deinit(allocator); var out: std.ArrayList(EtfMetricRecord) = .empty; defer { for (out.items) |*r| r.deinit(allocator); out.deinit(allocator); } try appendEtfMetricRecords(allocator, &out, metrics); // Expect 1 profile + 2 sectors + 3 holdings = 6 records. try std.testing.expectEqual(@as(usize, 6), out.items.len); // First is profile. try std.testing.expect(out.items[0] == .profile); try std.testing.expectEqualStrings("VTI", out.items[0].profile.symbol); try std.testing.expectEqualStrings("0000036405", out.items[0].profile.cik); try std.testing.expectEqualStrings("S000002848", out.items[0].profile.series_id orelse return error.SeriesIdMissing); // Next two are sectors. try std.testing.expect(out.items[1] == .sector); try std.testing.expectEqualStrings("EC/CORP", out.items[1].sector.code); try std.testing.expectEqualStrings("Equity / Corporate", out.items[1].sector.description); try std.testing.expect(out.items[2] == .sector); // Last three are holdings. try std.testing.expect(out.items[3] == .holding); try std.testing.expect(out.items[4] == .holding); try std.testing.expect(out.items[5] == .holding); try std.testing.expectEqualStrings("AAPL", out.items[3].holding.ticker orelse return error.TickerMissing); try std.testing.expectEqualStrings("VTI", out.items[3].holding.symbol); // fund symbol, not holding's } // ── TickerEntry / TickerMap tests ──────────────────────────── test "MutualFundTickerEntry.deinit frees all owned strings" { // Round-trip via testing.allocator: any leak fails the test. const allocator = std.testing.allocator; const entry: MutualFundTickerEntry = .{ .symbol = try allocator.dupe(u8, "VTI"), .cik = try allocator.dupe(u8, "0000036405"), .series_id = try allocator.dupe(u8, "S000002848"), .class_id = try allocator.dupe(u8, "C000007808"), .title = try allocator.dupe(u8, "Vanguard Total Stock Market"), }; entry.deinit(allocator); } test "MutualFundTickerEntry.deinit handles all-null optionals" { const allocator = std.testing.allocator; const entry: MutualFundTickerEntry = .{ .symbol = try allocator.dupe(u8, "VTI"), .cik = try allocator.dupe(u8, "0000036405"), }; entry.deinit(allocator); } test "MutualFundTickerEntry.freeSlice frees each entry and the slice" { const allocator = std.testing.allocator; var list: std.ArrayList(MutualFundTickerEntry) = .empty; errdefer list.deinit(allocator); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "VTI"), .cik = try allocator.dupe(u8, "0000036405"), .series_id = try allocator.dupe(u8, "S000002848"), .class_id = try allocator.dupe(u8, "C000007808"), }); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "AGG"), .cik = try allocator.dupe(u8, "0001100663"), }); const entries = try list.toOwnedSlice(allocator); MutualFundTickerEntry.freeSlice(allocator, entries); } test "CompanyTickerEntry.deinit frees all owned strings" { const allocator = std.testing.allocator; const entry: CompanyTickerEntry = .{ .symbol = try allocator.dupe(u8, "SPY"), .cik = try allocator.dupe(u8, "0000078462"), .title = try allocator.dupe(u8, "SPDR S&P 500 ETF Trust"), }; entry.deinit(allocator); } test "CompanyTickerEntry.freeSlice frees each entry and the slice" { const allocator = std.testing.allocator; var list: std.ArrayList(CompanyTickerEntry) = .empty; errdefer list.deinit(allocator); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "SPY"), .cik = try allocator.dupe(u8, "0000078462"), .title = try allocator.dupe(u8, "SPDR S&P 500 ETF Trust"), }); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "GLD"), .cik = try allocator.dupe(u8, "0001222333"), }); const entries = try list.toOwnedSlice(allocator); CompanyTickerEntry.freeSlice(allocator, entries); } test "TickerMap.fromEntries: get returns matching entry by symbol" { const allocator = std.testing.allocator; var list: std.ArrayList(MutualFundTickerEntry) = .empty; errdefer list.deinit(allocator); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "VTI"), .cik = try allocator.dupe(u8, "0000036405"), .series_id = try allocator.dupe(u8, "S000002848"), .class_id = try allocator.dupe(u8, "C000007808"), }); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "AGG"), .cik = try allocator.dupe(u8, "0001100663"), .series_id = try allocator.dupe(u8, "S000004362"), .class_id = try allocator.dupe(u8, "C000012092"), }); const entries = try list.toOwnedSlice(allocator); var map = try TickerMap(MutualFundTickerEntry).fromEntries(allocator, entries); defer map.deinit(); const vti = map.get("VTI") orelse return error.TestFailed; try std.testing.expectEqualStrings("0000036405", vti.cik); const agg = map.get("AGG") orelse return error.TestFailed; try std.testing.expectEqualStrings("0001100663", agg.cik); try std.testing.expect(map.get("DOES_NOT_EXIST") == null); } test "TickerMap.fromEntries: first-wins on duplicate symbols" { // A duplicate ticker (same symbol, different CIK) keeps the // first occurrence and silently shadows the second. Mirrors // the behavior of the old in-parser hashmap. const allocator = std.testing.allocator; var list: std.ArrayList(MutualFundTickerEntry) = .empty; errdefer list.deinit(allocator); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "DUP"), .cik = try allocator.dupe(u8, "0000000001"), }); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "DUP"), .cik = try allocator.dupe(u8, "0000000002"), }); const entries = try list.toOwnedSlice(allocator); var map = try TickerMap(MutualFundTickerEntry).fromEntries(allocator, entries); defer map.deinit(); const dup = map.get("DUP") orelse return error.TestFailed; try std.testing.expectEqualStrings("0000000001", dup.cik); } test "TickerMap.fromEntries: empty slice produces empty map" { const allocator = std.testing.allocator; const entries: []MutualFundTickerEntry = &.{}; var map = try TickerMap(MutualFundTickerEntry).fromEntries(allocator, try allocator.dupe(MutualFundTickerEntry, entries)); defer map.deinit(); try std.testing.expect(map.get("VTI") == null); } test "TickerMap(CompanyTickerEntry): borrow + lookup round-trip" { const allocator = std.testing.allocator; var list: std.ArrayList(CompanyTickerEntry) = .empty; errdefer list.deinit(allocator); try list.append(allocator, .{ .symbol = try allocator.dupe(u8, "SPY"), .cik = try allocator.dupe(u8, "0000078462"), .title = try allocator.dupe(u8, "SPDR S&P 500 ETF Trust"), }); const entries = try list.toOwnedSlice(allocator); var map = try TickerMap(CompanyTickerEntry).fromEntries(allocator, entries); defer map.deinit(); const spy = map.get("SPY") orelse return error.TestFailed; try std.testing.expectEqualStrings("SPY", spy.symbol); try std.testing.expectEqualStrings("SPDR S&P 500 ETF Trust", spy.title orelse return error.TitleMissing); } test "parseTickerMap: duplicate symbol rows produce one entry (first-wins)" { // Real-world: SEC's mutual-fund file occasionally has multiple // class IDs sharing a ticker. We keep the first row and skip // the rest rather than emitting both and letting TickerMap // dedupe - the slice itself is the cache, so we want it // canonical. const fixture = \\{"fields":["cik","seriesId","classId","symbol"],"data":[ \\ [36405,"S000002848","C000007808","DUP"], \\ [99999,"S000099999","C000099999","DUP"] \\]} ; const allocator = std.testing.allocator; const entries = try parseTickerMap(allocator, fixture); defer MutualFundTickerEntry.freeSlice(allocator, entries); try std.testing.expectEqual(@as(usize, 1), entries.len); try std.testing.expectEqualStrings("0000036405", entries[0].cik); } test "parseStockTickerMap: duplicate symbol rows produce one entry (first-wins)" { const fixture = \\{ \\ "0":{"cik_str":78462,"ticker":"DUP","title":"First"}, \\ "1":{"cik_str":99999,"ticker":"DUP","title":"Second"} \\} ; const allocator = std.testing.allocator; const entries = try parseStockTickerMap(allocator, fixture); defer CompanyTickerEntry.freeSlice(allocator, entries); try std.testing.expectEqual(@as(usize, 1), entries.len); try std.testing.expectEqualStrings("First", entries[0].title orelse return error.TitleMissing); } // ── parseLatestNportPFromSearch ──────────────────────────────── test "parseLatestNportPFromSearch: picks newest by file_date and builds canonical URL" { // SEC EDGAR full-text-search returns multiple NPORT-P // filings; we want the latest by file_date (lex-max on // YYYY-MM-DD). The URL is built from CIK (zero-stripped) // and adsh (dashes stripped). const fixture = \\{ \\ "hits": { \\ "hits": [ \\ {"_source":{"file_date":"2024-08-15","adsh":"0000123456-24-000001","ciks":["0001378872"]}}, \\ {"_source":{"file_date":"2025-02-20","adsh":"0001234567-25-000099","ciks":["0001378872"]}}, \\ {"_source":{"file_date":"2024-11-10","adsh":"0009999999-24-000050","ciks":["0001378872"]}} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url != null); try std.testing.expectEqualStrings( "https://www.sec.gov/Archives/edgar/data/1378872/000123456725000099/primary_doc.xml", url.?, ); } test "parseLatestNportPFromSearch: empty hits returns null" { const fixture = \\{"hits":{"hits":[]}} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url == null); } test "parseLatestNportPFromSearch: missing hits.hits returns null" { const fixture = \\{"hits":{}} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url == null); } test "parseLatestNportPFromSearch: non-object root returns null" { const fixture = "[1,2,3]"; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url == null); } test "parseLatestNportPFromSearch: hit with non-object _source skipped, falls through" { // Defensive: if a hit has malformed _source, we skip it and // continue. As long as at least one valid hit exists, we // return its URL. const fixture = \\{ \\ "hits": { \\ "hits": [ \\ {"_source":"malformed"}, \\ {"_source":{"file_date":"2025-01-01","adsh":"0000000001-25-000001","ciks":["0000000001"]}} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url != null); try std.testing.expect(std.mem.indexOf(u8, url.?, "0000000001") != null); } test "parseLatestNportPFromSearch: empty ciks array returns null" { const fixture = \\{ \\ "hits": { \\ "hits": [ \\ {"_source":{"file_date":"2025-01-01","adsh":"0000000001-25-000001","ciks":[]}} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url == null); } test "parseLatestNportPFromSearch: CIK with all leading zeros except one preserved" { // Edge case: CIK like "0000000001" should strip to "1", // not "" (the loop condition is `len > 1`). const fixture = \\{ \\ "hits": { \\ "hits": [ \\ {"_source":{"file_date":"2025-01-01","adsh":"abc-25-001","ciks":["0000000001"]}} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const url = try parseLatestNportPFromSearch(allocator, fixture); defer if (url) |u| allocator.free(u); try std.testing.expect(url != null); try std.testing.expect(std.mem.indexOf(u8, url.?, "/data/1/") != null); } // ── parseSharesOutstanding ──────────────────────────────────── test "parseSharesOutstanding: picks latest by `end` date and returns value+form" { const fixture = \\{ \\ "units": { \\ "shares": [ \\ {"end":"2024-03-31","val":15000000000,"form":"10-Q"}, \\ {"end":"2025-06-30","val":15500000000,"form":"10-Q"}, \\ {"end":"2024-12-31","val":15300000000,"form":"10-K"} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); try std.testing.expect(result != null); defer result.?.deinit(allocator); try std.testing.expectEqual(@as(u64, 15500000000), result.?.value); try std.testing.expectEqualStrings("2025-06-30", result.?.period_end); try std.testing.expectEqualStrings("10-Q", result.?.form); } test "parseSharesOutstanding: float val coerces to u64" { // EDGAR sometimes serializes huge counts as floats. Should // round-trip cleanly. const fixture = \\{ \\ "units": { \\ "shares": [ \\ {"end":"2025-01-01","val":1234567890.0,"form":"10-K"} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); try std.testing.expect(result != null); defer result.?.deinit(allocator); try std.testing.expectEqual(@as(u64, 1234567890), result.?.value); } test "parseSharesOutstanding: negative val rejected (returns null)" { const fixture = \\{ \\ "units": { \\ "shares": [ \\ {"end":"2025-01-01","val":-1,"form":"10-K"} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: missing units -> null" { const fixture = "{}"; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: empty units object -> null" { const fixture = \\{"units":{}} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: empty rows array -> null" { const fixture = \\{"units":{"shares":[]}} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: missing form field defaults to empty string" { const fixture = \\{ \\ "units": { \\ "shares": [ \\ {"end":"2025-01-01","val":1000} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); try std.testing.expect(result != null); defer result.?.deinit(allocator); try std.testing.expectEqualStrings("", result.?.form); } test "parseSharesOutstanding: non-object root -> null" { const fixture = "[]"; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: row with non-string end skipped" { // Defensive: malformed end falls through; if no valid rows // remain, returns null. const fixture = \\{ \\ "units": { \\ "shares": [ \\ {"end":12345,"val":1000,"form":"X"} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); if (result) |r| r.deinit(allocator); try std.testing.expect(result == null); } test "parseSharesOutstanding: takes first units key when multiple present" { // Defensive: if EDGAR ever serves multiple unit keys (it // usually doesn't for shares), we take the first non-empty // one rather than crashing. const fixture = \\{ \\ "units": { \\ "USD/shares": [], \\ "shares": [ \\ {"end":"2025-01-01","val":42,"form":"10-K"} \\ ] \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSharesOutstanding(allocator, fixture); try std.testing.expect(result != null); defer result.?.deinit(allocator); try std.testing.expectEqual(@as(u64, 42), result.?.value); } // ── parseSubmissionsFeed (incl. findNportPUrlInSubmissions) ──── test "parseSubmissionsFeed: extracts entity metadata + latest NPORT-P URL" { const fixture = \\{ \\ "name":"Acme Funds Trust", \\ "entityType":"investment company", \\ "sicDescription":"Investment Companies", \\ "filings":{ \\ "recent":{ \\ "form":["10-K","NPORT-P","NPORT-P","8-K"], \\ "accessionNumber":["0000000001-25-000001","0000000002-25-000001","0000000003-25-000050","0000000004-25-000099"], \\ "filingDate":["2025-01-01","2024-08-15","2025-02-20","2025-03-01"] \\ } \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0001378872"); defer result.deinit(allocator); try std.testing.expectEqualStrings("Acme Funds Trust", result.entity_name.?); try std.testing.expectEqualStrings("investment company", result.entity_type.?); try std.testing.expectEqualStrings("Investment Companies", result.sic_description.?); // Latest NPORT-P is index 2 (filingDate 2025-02-20), not // index 1. URL builds from accession 0000000003-25-000050. try std.testing.expect(result.latest_nport_p_url != null); try std.testing.expectEqualStrings( "https://www.sec.gov/Archives/edgar/data/1378872/000000000325000050/primary_doc.xml", result.latest_nport_p_url.?, ); } test "parseSubmissionsFeed: no NPORT-P filings -> latest_nport_p_url is null" { const fixture = \\{ \\ "name":"Plain Stock Inc", \\ "entityType":"operating", \\ "filings":{ \\ "recent":{ \\ "form":["10-K","8-K","10-Q"], \\ "accessionNumber":["a","b","c"], \\ "filingDate":["2025-01-01","2025-02-01","2025-03-01"] \\ } \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0000123456"); defer result.deinit(allocator); try std.testing.expectEqualStrings("Plain Stock Inc", result.entity_name.?); try std.testing.expect(result.latest_nport_p_url == null); } test "parseSubmissionsFeed: empty sicDescription not duped" { // Defensive: SEC sometimes returns an empty string for // sicDescription. We should leave the field null rather // than dupe an empty string. const fixture = \\{ \\ "name":"X", \\ "sicDescription":"", \\ "filings":{"recent":{"form":[],"accessionNumber":[],"filingDate":[]}} \\} ; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0000000001"); defer result.deinit(allocator); try std.testing.expect(result.sic_description == null); } test "parseSubmissionsFeed: missing filings object returns metadata-only summary" { const fixture = \\{"name":"Bare Entity","entityType":"trust"} ; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0000000001"); defer result.deinit(allocator); try std.testing.expectEqualStrings("Bare Entity", result.entity_name.?); try std.testing.expect(result.latest_nport_p_url == null); } test "parseSubmissionsFeed: non-object root returns empty summary" { const fixture = "[]"; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0000000001"); defer result.deinit(allocator); try std.testing.expect(result.entity_name == null); try std.testing.expect(result.latest_nport_p_url == null); } test "parseSubmissionsFeed: NPORT-P with date-array shorter than form-array skips OOB" { // Defensive: form/date/accession arrays should be parallel, // but if `dates` is short we must not OOB-read. The // `if (i >= dates.len) continue` guard exercises this. const fixture = \\{ \\ "name":"X", \\ "filings":{ \\ "recent":{ \\ "form":["NPORT-P","NPORT-P"], \\ "accessionNumber":["a","b"], \\ "filingDate":["2025-01-01"] \\ } \\ } \\} ; const allocator = std.testing.allocator; const result = try parseSubmissionsFeed(allocator, fixture, "0000000001"); defer result.deinit(allocator); // Index 0 has a matching date, so we still find a URL. try std.testing.expect(result.latest_nport_p_url != null); }