990 lines
42 KiB
Zig
990 lines
42 KiB
Zig
/// Classification metadata for portfolio analysis.
|
|
///
|
|
/// Each entry maps a symbol to one or more asset class / sector / geographic allocations.
|
|
/// For individual stocks, there's typically one entry at 100%.
|
|
/// For blended funds (e.g., target date), there can be multiple entries that sum to ~100%.
|
|
///
|
|
/// Loaded from a metadata SRF file like `metadata.srf`:
|
|
/// symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap
|
|
/// symbol::02315N600,asset_class::US Large Cap,pct:num:55
|
|
/// symbol::02315N600,asset_class::International Developed,pct:num:20
|
|
/// symbol::02315N600,asset_class::Bonds,pct:num:15
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const srf = @import("srf");
|
|
const srf_opts = @import("../srf_opts.zig");
|
|
const Date = @import("../Date.zig");
|
|
|
|
const log = std.log.scoped(.metadata);
|
|
|
|
/// A single classification entry for a symbol.
|
|
pub const ClassificationEntry = struct {
|
|
symbol: []const u8,
|
|
/// Human-readable security name (e.g., "Amazon", "SPDR S&P 500
|
|
/// ETF Trust"). Optional - older metadata.srf files may not
|
|
/// have this field. Renderers fall back to `symbol` /
|
|
/// `display_symbol` when null.
|
|
name: ?[]const u8 = null,
|
|
/// User-curated grouping label that overrides the auto-derived
|
|
/// bucket for concentration / dominance checks and the
|
|
/// analysis tab's Sector breakdown. Use this when the upstream
|
|
/// `sector` field is the NPORT-P "Equity / Corporate" mush
|
|
/// that doesn't actually distinguish your holdings (e.g. SPY
|
|
/// vs FRDM vs HFXI all tagged the same way). When null,
|
|
/// `deriveBucket` falls back to a sensible default.
|
|
bucket: ?[]const u8 = null,
|
|
/// Sector (e.g., "Technology", "Healthcare", "Financials")
|
|
sector: ?[]const u8 = null,
|
|
/// Geographic region (e.g., "US", "International Developed", "Emerging Markets")
|
|
geo: ?[]const u8 = null,
|
|
/// Asset class (e.g., "US Large Cap", "Bonds", "Cash")
|
|
asset_class: ?[]const u8 = null,
|
|
/// Percentage weight for this entry (0-100). Default 100 for single-class assets.
|
|
pct: f64 = 100.0,
|
|
/// Per-symbol opt-in for automatic stock-split adjustment. When set,
|
|
/// `enrichSplits` auto-applies splits for THIS symbol that occur
|
|
/// AFTER this date (splits on/before it are assumed already baked
|
|
/// into the recorded share counts - e.g. a lot entered post-split).
|
|
/// Null (the default, and the state of every metadata.srf that
|
|
/// predates this feature) means split adjustment is OFF for the
|
|
/// symbol - today's exact behavior. It lives per-symbol because
|
|
/// "when did I last make these shares current" is a fact about the
|
|
/// individual holding, not the whole portfolio.
|
|
splits_current_through: ?Date = null,
|
|
};
|
|
|
|
/// Parsed classification data for the entire portfolio.
|
|
pub const ClassificationMap = struct {
|
|
entries: []ClassificationEntry,
|
|
allocator: std.mem.Allocator,
|
|
|
|
pub fn deinit(self: *ClassificationMap) void {
|
|
for (self.entries) |e| {
|
|
self.allocator.free(e.symbol);
|
|
if (e.name) |n| self.allocator.free(n);
|
|
if (e.bucket) |b| self.allocator.free(b);
|
|
if (e.sector) |s| self.allocator.free(s);
|
|
if (e.geo) |g| self.allocator.free(g);
|
|
if (e.asset_class) |a| self.allocator.free(a);
|
|
}
|
|
self.allocator.free(self.entries);
|
|
}
|
|
};
|
|
|
|
/// Parse a metadata SRF file into a ClassificationMap.
|
|
/// Each record has: symbol::<SYM>,name::<N>,bucket::<B>,sector::<S>,geo::<G>,asset_class::<A>,pct:num:<P>
|
|
/// All fields except symbol are optional. pct defaults to 100.
|
|
pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !ClassificationMap {
|
|
var entries = std.ArrayList(ClassificationEntry).empty;
|
|
errdefer {
|
|
for (entries.items) |e| {
|
|
allocator.free(e.symbol);
|
|
if (e.name) |n| allocator.free(n);
|
|
if (e.bucket) |b| allocator.free(b);
|
|
if (e.sector) |s| allocator.free(s);
|
|
if (e.geo) |g| allocator.free(g);
|
|
if (e.asset_class) |a| allocator.free(a);
|
|
}
|
|
entries.deinit(allocator);
|
|
}
|
|
|
|
var reader = std.Io.Reader.fixed(data);
|
|
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
|
|
defer it.deinit();
|
|
|
|
while (try it.next()) |fields| {
|
|
const entry = fields.to(ClassificationEntry, srf_opts.user_edited) catch |err| {
|
|
// Skip the row rather than losing the whole file, but name
|
|
// the error: a silently dropped row quietly changes a
|
|
// breakdown's percentages. Quiet under `zig build test`,
|
|
// where fixtures feed malformed rows on purpose.
|
|
if (!builtin.is_test) {
|
|
log.warn("metadata.srf: skipping malformed record: {s}", .{@errorName(err)});
|
|
}
|
|
continue;
|
|
};
|
|
// Pre-fill `bucket` if the user didn't curate one. This
|
|
// shifts the cost of `deriveBucket` to parse time and
|
|
// makes downstream code free to read `entry.bucket`
|
|
// directly without juggling allocator parameters.
|
|
const built_bucket: []const u8 = if (entry.bucket) |b|
|
|
try allocator.dupe(u8, b)
|
|
else
|
|
try deriveBucket(entry, allocator);
|
|
try entries.append(allocator, .{
|
|
.symbol = try allocator.dupe(u8, entry.symbol),
|
|
.name = if (entry.name) |n| try allocator.dupe(u8, n) else null,
|
|
.bucket = built_bucket,
|
|
.sector = if (entry.sector) |s| try allocator.dupe(u8, s) else null,
|
|
.geo = if (entry.geo) |g| try allocator.dupe(u8, g) else null,
|
|
.asset_class = if (entry.asset_class) |a| try allocator.dupe(u8, a) else null,
|
|
.pct = entry.pct,
|
|
.splits_current_through = entry.splits_current_through,
|
|
});
|
|
}
|
|
|
|
return .{
|
|
.entries = try entries.toOwnedSlice(allocator),
|
|
.allocator = allocator,
|
|
};
|
|
}
|
|
|
|
/// Resolve a classification entry to its display bucket. Used by
|
|
/// the review tab's Sector column, by `analyzePortfolio`'s sector
|
|
/// rollup, and by the observation engine's concentration /
|
|
/// dominance checks.
|
|
///
|
|
/// Four-tier fallback (caller owns the returned slice; allocated
|
|
/// via `allocator`):
|
|
/// 1. `entry.bucket` if set - user-curated, always wins.
|
|
/// 2. `entry.sector` if set AND doesn't contain '/' - GICS-style
|
|
/// sector ("Technology", "Healthcare"). The '/' rules out
|
|
/// NPORT-P fund-decomp categories ("Equity / Corporate")
|
|
/// that are noise rather than meaningful sectors.
|
|
/// 3. Composite "<geo> <asset_class>" if both are set. For
|
|
/// funds without a curated bucket, this gives a meaningful
|
|
/// grouping like "International Developed Fund" or "US ETF".
|
|
/// 4. Literal "Unclassified".
|
|
pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![]const u8 {
|
|
if (entry.bucket) |b| return try allocator.dupe(u8, b);
|
|
if (entry.sector) |s| {
|
|
if (std.mem.indexOfScalar(u8, s, '/') == null) return try allocator.dupe(u8, s);
|
|
}
|
|
if (entry.geo != null and entry.asset_class != null) {
|
|
const g = entry.geo.?;
|
|
const ac = entry.asset_class.?;
|
|
// Avoid duplicate-geo composites like "US US Large Cap".
|
|
// If the asset_class starts with the geo prefix (followed
|
|
// by a space or end-of-string), use it alone. Same for
|
|
// common geographic-noun asset classes that already imply
|
|
// their region ("International Developed", "Emerging
|
|
// Markets") - these don't need a geo prefix.
|
|
const ac_starts_with_geo = std.mem.startsWith(u8, ac, g) and
|
|
(ac.len == g.len or ac[g.len] == ' ');
|
|
const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or
|
|
std.mem.startsWith(u8, ac, "Emerging");
|
|
if (ac_starts_with_geo or ac_has_implicit_geo) {
|
|
return try allocator.dupe(u8, ac);
|
|
}
|
|
return try std.fmt.allocPrint(allocator, "{s} {s}", .{ g, ac });
|
|
}
|
|
return try allocator.dupe(u8, "Unclassified");
|
|
}
|
|
|
|
/// The candidate name sources for a security, other than the curated
|
|
/// `metadata.srf` name (which `resolveSecurityName` looks up from the
|
|
/// classification map and ranks above all of these). Callers fill in
|
|
/// whatever they have on hand and leave the rest null; the precedence
|
|
/// *between* these sources is decided by `resolveSecurityName`, not by
|
|
/// the caller - that's the whole point of routing every surface
|
|
/// through one function.
|
|
pub const NameSources = struct {
|
|
/// The live quote provider's name (Yahoo `longName`), read from the
|
|
/// same response as the price. Ranked above the ETF profile because
|
|
/// it reflects what currently trades under the ticker and so
|
|
/// self-heals when a ticker is recycled (e.g. SPCX: SpaceX vs. the
|
|
/// defunct "SPAC and New Issue ETF").
|
|
live_quote: ?[]const u8 = null,
|
|
/// The ETF/fund profile name (EDGAR series name or Wikidata name).
|
|
etf_profile: ?[]const u8 = null,
|
|
};
|
|
|
|
/// Resolve a human-readable security name for `symbol`. This is the
|
|
/// single home for the name-precedence *policy*; every surface (the
|
|
/// CLI `quote` command, the TUI quote tab, the 'K' overlay) calls it
|
|
/// so they cannot drift. The policy, highest priority first:
|
|
///
|
|
/// 1. The curated `name::` from `metadata.srf` (via `cm`) - the
|
|
/// user's explicit override.
|
|
/// 2. `sources.live_quote` - the live quote provider name.
|
|
/// 3. `sources.etf_profile` - the ETF/fund profile name.
|
|
///
|
|
/// Empty names are skipped at every level. Returns a slice borrowed
|
|
/// from `cm` or one of `sources` (keep them alive while the result is
|
|
/// used), or null when no source yields a name. Symbol comparison is
|
|
/// exact (`std.mem.eql`); every caller upper-cases symbols first,
|
|
/// matching the upper-case symbols `metadata.srf` carries.
|
|
pub fn resolveSecurityName(
|
|
symbol: []const u8,
|
|
cm: ?*const ClassificationMap,
|
|
sources: NameSources,
|
|
) ?[]const u8 {
|
|
// 1. Curated metadata name wins. First matching entry that carries
|
|
// a name wins; blended-fund symbols repeat across rows with the
|
|
// same name, so this is well-defined.
|
|
if (cm) |m| {
|
|
for (m.entries) |*e| {
|
|
if (std.mem.eql(u8, e.symbol, symbol)) {
|
|
if (e.name) |n| {
|
|
if (n.len > 0) return n;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 2. then the live quote name, 3. then the ETF profile name.
|
|
if (nonEmptyName(sources.live_quote)) |n| return n;
|
|
if (nonEmptyName(sources.etf_profile)) |n| return n;
|
|
return null;
|
|
}
|
|
|
|
/// An optional name, treating empty as absent.
|
|
fn nonEmptyName(s: ?[]const u8) ?[]const u8 {
|
|
const v = s orelse return null;
|
|
return if (v.len > 0) v else null;
|
|
}
|
|
|
|
test "parse classification file" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\# Stock: single sector
|
|
\\symbol::AMZN,name::Amazon,sector::Technology,geo::US,asset_class::US Large Cap
|
|
\\
|
|
\\# Target date fund: blended
|
|
\\symbol::TGT2035,name::Target Retirement 2035,asset_class::US Large Cap,pct:num:55
|
|
\\symbol::TGT2035,name::Target Retirement 2035,asset_class::Bonds,pct:num:15
|
|
\\symbol::TGT2035,name::Target Retirement 2035,asset_class::International Developed,pct:num:20
|
|
;
|
|
const allocator = std.testing.allocator;
|
|
var cm = try parseClassificationFile(allocator, data);
|
|
defer cm.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 4), cm.entries.len);
|
|
try std.testing.expectEqualStrings("AMZN", cm.entries[0].symbol);
|
|
try std.testing.expectEqualStrings("Amazon", cm.entries[0].name.?);
|
|
try std.testing.expectEqualStrings("Technology", cm.entries[0].sector.?);
|
|
try std.testing.expectEqualStrings("US", cm.entries[0].geo.?);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100.0), cm.entries[0].pct, 0.01);
|
|
|
|
try std.testing.expectEqualStrings("TGT2035", cm.entries[1].symbol);
|
|
try std.testing.expectEqualStrings("Target Retirement 2035", cm.entries[1].name.?);
|
|
try std.testing.expectEqualStrings("US Large Cap", cm.entries[1].asset_class.?);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 55.0), cm.entries[1].pct, 0.01);
|
|
}
|
|
|
|
test "parse classification file: missing name field stays null (backwards compat)" {
|
|
// Older metadata.srf files predate the name:: field. Parsing
|
|
// must still succeed; consumers fall back to symbol /
|
|
// display_symbol when name is null.
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap
|
|
;
|
|
const allocator = std.testing.allocator;
|
|
var cm = try parseClassificationFile(allocator, data);
|
|
defer cm.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), cm.entries.len);
|
|
try std.testing.expectEqualStrings("AMZN", cm.entries[0].symbol);
|
|
try std.testing.expect(cm.entries[0].name == null);
|
|
// `bucket` is pre-filled by the parser via deriveBucket. For
|
|
// a GICS-style sector ("Technology"), it equals the sector.
|
|
try std.testing.expectEqualStrings("Technology", cm.entries[0].bucket.?);
|
|
try std.testing.expectEqualStrings("Technology", cm.entries[0].sector.?);
|
|
}
|
|
|
|
test "parse classification file: a hand-typed string separator on pct still parses" {
|
|
// `metadata.srf` is hand-edited, so `pct::60` instead of `pct:num:60`
|
|
// must not be fatal. See `srf_opts.user_edited` - under strict
|
|
// coercion this string reaches an unchecked `val.?.number`, which is
|
|
// undefined behaviour in ReleaseFast.
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::SYM,sector::Technology,pct::60
|
|
\\symbol::SYM,sector::Healthcare,pct:num:40
|
|
;
|
|
var map = try parseClassificationFile(std.testing.allocator, data);
|
|
defer map.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), map.entries.len);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 60), map.entries[0].pct, 0.001);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 40), map.entries[1].pct, 0.001);
|
|
}
|
|
|
|
test "parse classification file: a record missing symbol is skipped, not fatal" {
|
|
// `symbol` has no default, so a record without it fails coercion.
|
|
// One bad row must not cost the whole file - every other symbol's
|
|
// sector and geo would silently vanish from the breakdowns.
|
|
const prev_level = std.testing.log_level;
|
|
std.testing.log_level = .err;
|
|
defer std.testing.log_level = prev_level;
|
|
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::AAPL,sector::Technology
|
|
\\sector::Healthcare,pct:num:100
|
|
\\symbol::MSFT,sector::Technology
|
|
;
|
|
var map = try parseClassificationFile(std.testing.allocator, data);
|
|
defer map.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), map.entries.len);
|
|
try std.testing.expectEqualStrings("AAPL", map.entries[0].symbol);
|
|
try std.testing.expectEqualStrings("MSFT", map.entries[1].symbol);
|
|
}
|
|
|
|
test "parse classification file: bucket round-trips" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::SPY,name::SPDR S&P 500 ETF Trust,bucket::US Large Cap,sector::Equity / Corporate,geo::US,asset_class::ETF
|
|
;
|
|
const allocator = std.testing.allocator;
|
|
var cm = try parseClassificationFile(allocator, data);
|
|
defer cm.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), cm.entries.len);
|
|
try std.testing.expectEqualStrings("SPY", cm.entries[0].symbol);
|
|
try std.testing.expectEqualStrings("US Large Cap", cm.entries[0].bucket.?);
|
|
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
|
|
}
|
|
|
|
test "parse classification file: per-symbol splits_current_through parses; absent stays null" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::AMZN,name::Amazon,sector::Technology,geo::US,asset_class::US Large Cap,splits_current_through::2024-06-01
|
|
\\symbol::NVDA,sector::Technology,geo::US,asset_class::US Large Cap
|
|
;
|
|
const allocator = std.testing.allocator;
|
|
var cm = try parseClassificationFile(allocator, data);
|
|
defer cm.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), cm.entries.len);
|
|
|
|
// AMZN carries a per-symbol cutover; NVDA (no field) stays null.
|
|
try std.testing.expectEqualStrings("AMZN", cm.entries[0].symbol);
|
|
try std.testing.expect(cm.entries[0].splits_current_through != null);
|
|
try std.testing.expect(cm.entries[0].splits_current_through.?.eql(Date.fromYmd(2024, 6, 1)));
|
|
|
|
try std.testing.expectEqualStrings("NVDA", cm.entries[1].symbol);
|
|
try std.testing.expect(cm.entries[1].splits_current_through == null);
|
|
}
|
|
|
|
test "parse classification file: legacy row without the field parses (backward compat)" {
|
|
const data =
|
|
\\#!srfv1
|
|
\\symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap
|
|
;
|
|
const allocator = std.testing.allocator;
|
|
var cm = try parseClassificationFile(allocator, data);
|
|
defer cm.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), cm.entries.len);
|
|
try std.testing.expect(cm.entries[0].splits_current_through == null);
|
|
}
|
|
|
|
test "resolveSecurityName: metadata name wins" {
|
|
var entries = [_]ClassificationEntry{
|
|
.{ .symbol = "AMZN", .name = "Amazon" },
|
|
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
|
};
|
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
|
// Metadata hit; lower-priority sources ignored.
|
|
try std.testing.expectEqualStrings(
|
|
"Amazon",
|
|
resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?,
|
|
);
|
|
try std.testing.expectEqualStrings(
|
|
"Vanguard Total Stock Market ETF",
|
|
resolveSecurityName("VTI", &cm, .{}).?,
|
|
);
|
|
}
|
|
|
|
test "resolveSecurityName: falls back when entry has no name" {
|
|
var entries = [_]ClassificationEntry{
|
|
.{ .symbol = "SOXX", .name = null }, // pre-name metadata row
|
|
};
|
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
|
try std.testing.expectEqualStrings(
|
|
"iShares Semiconductor ETF",
|
|
resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?,
|
|
);
|
|
// No other source either -> null.
|
|
try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null);
|
|
}
|
|
|
|
test "resolveSecurityName: falls back when symbol absent from map" {
|
|
var entries = [_]ClassificationEntry{
|
|
.{ .symbol = "AMZN", .name = "Amazon" },
|
|
};
|
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
|
try std.testing.expectEqualStrings(
|
|
"SPDR S&P 500 ETF Trust",
|
|
resolveSecurityName("SPY", &cm, .{ .etf_profile = "SPDR S&P 500 ETF Trust" }).?,
|
|
);
|
|
}
|
|
|
|
test "resolveSecurityName: null map uses a source, null everything is null" {
|
|
try std.testing.expectEqualStrings(
|
|
"Apple Inc.",
|
|
resolveSecurityName("AAPL", null, .{ .etf_profile = "Apple Inc." }).?,
|
|
);
|
|
try std.testing.expect(resolveSecurityName("AAPL", null, .{}) == null);
|
|
}
|
|
|
|
test "resolveSecurityName: empty metadata name treated as absent" {
|
|
var entries = [_]ClassificationEntry{
|
|
.{ .symbol = "AAPL", .name = "" },
|
|
};
|
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
|
try std.testing.expectEqualStrings(
|
|
"Apple Inc.",
|
|
resolveSecurityName("AAPL", &cm, .{ .etf_profile = "Apple Inc." }).?,
|
|
);
|
|
}
|
|
|
|
test "resolveSecurityName: precedence is metadata > live quote > etf profile" {
|
|
// No metadata: the live quote name beats the ETF profile name. This
|
|
// is the SPCX case - the live Yahoo name wins over the stale
|
|
// recycled-ticker ETF name. The ordering lives in the function, not
|
|
// in the caller, so this test pins the policy itself.
|
|
try std.testing.expectEqualStrings(
|
|
"Space Exploration Technologies Corp.",
|
|
resolveSecurityName("SPCX", null, .{
|
|
.live_quote = "Space Exploration Technologies Corp.",
|
|
.etf_profile = "The SPAC and New Issue ETF",
|
|
}).?,
|
|
);
|
|
// An empty live quote name is skipped, so the ETF profile shows.
|
|
try std.testing.expectEqualStrings(
|
|
"The SPAC and New Issue ETF",
|
|
resolveSecurityName("SPCX", null, .{
|
|
.live_quote = "",
|
|
.etf_profile = "The SPAC and New Issue ETF",
|
|
}).?,
|
|
);
|
|
// Metadata still outranks both lower-priority sources.
|
|
var entries = [_]ClassificationEntry{.{ .symbol = "SPCX", .name = "My Override" }};
|
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
|
try std.testing.expectEqualStrings(
|
|
"My Override",
|
|
resolveSecurityName("SPCX", &cm, .{
|
|
.live_quote = "Space Exploration Technologies Corp.",
|
|
.etf_profile = "The SPAC and New Issue ETF",
|
|
}).?,
|
|
);
|
|
// No sources at all -> null.
|
|
try std.testing.expect(resolveSecurityName("SPCX", null, .{}) == null);
|
|
}
|
|
|
|
test "deriveBucket: returns user-curated bucket when set" {
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "SPY",
|
|
.bucket = "US Large Cap",
|
|
.sector = "Equity / Corporate", // would otherwise force fallback
|
|
.geo = "US",
|
|
.asset_class = "ETF",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("US Large Cap", out);
|
|
}
|
|
|
|
test "deriveBucket: returns sector when GICS-like (no '/')" {
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "AMZN",
|
|
.sector = "Technology",
|
|
.geo = "US",
|
|
.asset_class = "US Large Cap",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("Technology", out);
|
|
}
|
|
|
|
test "deriveBucket: composite fallback when sector is NPORT-P mush" {
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "HFXI",
|
|
.sector = "Equity / Corporate",
|
|
.geo = "International Developed",
|
|
.asset_class = "Fund",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("International Developed Fund", out);
|
|
}
|
|
|
|
test "deriveBucket: returns Unclassified when nothing usable is set" {
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "UNK",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("Unclassified", out);
|
|
}
|
|
|
|
test "deriveBucket: NPORT-P sector with no geo/asset_class falls through to Unclassified" {
|
|
// Defensive: sector is NPORT-P-style (skipped by the GICS
|
|
// filter) AND we don't have both geo and asset_class to
|
|
// build a composite. Falls through to Unclassified.
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "X",
|
|
.sector = "Debt / Corporate",
|
|
.geo = "US",
|
|
// asset_class missing
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("Unclassified", out);
|
|
}
|
|
|
|
test "deriveBucket: composite avoids duplicate geo when asset_class already starts with it" {
|
|
// Hand-written entries often have geographically-prefixed
|
|
// asset_class values like "US Large Cap" alongside
|
|
// geo="US". The naive composite "{geo} {asset_class}" then
|
|
// produces "US US Large Cap" which is ugly and clusters
|
|
// incorrectly in the breakdown. Detect the duplicate prefix
|
|
// and use the asset_class alone.
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "VOO",
|
|
.geo = "US",
|
|
.asset_class = "US Large Cap",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("US Large Cap", out);
|
|
}
|
|
|
|
test "deriveBucket: composite uses asset_class alone for International/Emerging implicit-geo classes" {
|
|
// "International Developed" and "Emerging Markets" are
|
|
// already geographic; the composite shouldn't re-prepend
|
|
// the geo.
|
|
const e1: ClassificationEntry = .{
|
|
.symbol = "VEA",
|
|
.geo = "International Developed",
|
|
.asset_class = "International Developed",
|
|
};
|
|
const out1 = try deriveBucket(e1, std.testing.allocator);
|
|
defer std.testing.allocator.free(out1);
|
|
try std.testing.expectEqualStrings("International Developed", out1);
|
|
|
|
const e2: ClassificationEntry = .{
|
|
.symbol = "VWO",
|
|
.geo = "Emerging Markets",
|
|
.asset_class = "Emerging Markets",
|
|
};
|
|
const out2 = try deriveBucket(e2, std.testing.allocator);
|
|
defer std.testing.allocator.free(out2);
|
|
try std.testing.expectEqualStrings("Emerging Markets", out2);
|
|
}
|
|
|
|
test "deriveBucket: composite still prepends geo when asset_class is generic (Fund/ETF/Bonds)" {
|
|
// The whole point of the composite is to disambiguate
|
|
// generic asset_class labels by their geo. Make sure we
|
|
// don't accidentally regress on this case while fixing
|
|
// the duplicate-prefix one.
|
|
const e: ClassificationEntry = .{
|
|
.symbol = "BND",
|
|
.geo = "US",
|
|
.asset_class = "Fund",
|
|
};
|
|
const out = try deriveBucket(e, std.testing.allocator);
|
|
defer std.testing.allocator.free(out);
|
|
try std.testing.expectEqualStrings("US Fund", out);
|
|
}
|
|
|
|
// ── ClassificationRecord ─────────────────────────────────────
|
|
//
|
|
// Distinct from `ClassificationEntry` above: that one represents
|
|
// a row in the user's `metadata.srf` (already-curated portfolio
|
|
// data). `ClassificationRecord` is the upstream-fetched
|
|
// per-symbol shape that flows OUT of `DataService.getClassification`.
|
|
// `enrich` reads it to write the metadata.srf row that becomes
|
|
// a `ClassificationEntry` later.
|
|
//
|
|
// Lives here (not in `providers/Wikidata.zig`) because the shape
|
|
// is provider-agnostic: any future classification source (FMP,
|
|
// Alpha Vantage, hand-written) populates the same record. The
|
|
// fact that today the only producer is Wikidata is incidental.
|
|
|
|
/// A single fetched classification result for one symbol.
|
|
///
|
|
/// All optional fields default to `null`; populators only set
|
|
/// the fields they have data for. The `source` field always
|
|
/// emits per the project's source-pure invariant.
|
|
pub const ClassificationRecord = struct {
|
|
symbol: []const u8, // owned
|
|
name: ?[]const u8 = null, // owned
|
|
sector: ?[]const u8 = null, // owned
|
|
industry: ?[]const u8 = null, // owned
|
|
/// ISO-3166 alpha-2 country code (e.g. "US", "GB", "DE").
|
|
country: ?[]const u8 = null, // owned
|
|
/// Resolved geo bucket (e.g. "US", "International Developed",
|
|
/// "Emerging Markets"). Populated either from `geoFor(country)`
|
|
/// or from title-keyword inference (`inferGeoFromTitle`) for
|
|
/// symbols where Wikidata didn't supply a country. Producers
|
|
/// pick one of the `geo.*` constants above; consumers compare
|
|
/// against the same constants.
|
|
geo: ?[]const u8 = null, // owned
|
|
asset_class: ?[]const u8 = null, // owned
|
|
is_etf: bool = false,
|
|
/// YYYY-MM-DD; trimmed from upstream's ISO-8601 date.
|
|
inception_date: ?[]const u8 = null, // owned
|
|
/// Wikidata's P5531 - the SEC CIK as a digit string. Already
|
|
/// zero-padded to 10 digits, matching the project-wide CIK
|
|
/// normalization convention.
|
|
cik: ?[]const u8 = null, // owned
|
|
/// YYYY-MM-DD when this provider ran, NOT when upstream last
|
|
/// updated the underlying entity.
|
|
as_of: []const u8, // owned
|
|
source: []const u8, // no default - provenance always emitted
|
|
|
|
pub fn deinit(self: ClassificationRecord, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.symbol);
|
|
if (self.name) |s| allocator.free(s);
|
|
if (self.sector) |s| allocator.free(s);
|
|
if (self.industry) |s| allocator.free(s);
|
|
if (self.country) |s| allocator.free(s);
|
|
if (self.geo) |s| allocator.free(s);
|
|
if (self.asset_class) |s| allocator.free(s);
|
|
if (self.inception_date) |s| allocator.free(s);
|
|
if (self.cik) |s| allocator.free(s);
|
|
allocator.free(self.as_of);
|
|
allocator.free(self.source);
|
|
}
|
|
|
|
/// Free a slice of records, calling deinit on each element first.
|
|
pub fn freeSlice(allocator: std.mem.Allocator, recs: []const ClassificationRecord) void {
|
|
for (recs) |r| r.deinit(allocator);
|
|
allocator.free(recs);
|
|
}
|
|
};
|
|
|
|
// ── Geographic taxonomy ──────────────────────────────────────
|
|
|
|
/// Geo-bucket constants used by the country -> geo lookup. Kept
|
|
/// as named constants (rather than inline string literals in the
|
|
/// map) so callers can reference them without typo risk and the
|
|
/// taxonomy is tweakable in one place.
|
|
pub const geo = struct {
|
|
pub const us = "US";
|
|
pub const developed = "International Developed";
|
|
pub const emerging = "Emerging Markets";
|
|
pub const unknown = "Unknown";
|
|
};
|
|
|
|
// ── Sector taxonomy ──────────────────────────────────────────
|
|
|
|
/// Canonical sector taxonomy (GICS-aligned 11-sector model).
|
|
/// Producers (Wikidata's `canonicalizeSector`, enrich's
|
|
/// `inferSectorFromTitle`) emit one of these strings; consumers
|
|
/// (analysis bucketing, display) compare against them.
|
|
///
|
|
/// Lives here (not in any provider) so multiple producers can
|
|
/// share one taxonomy. Adding a 12th sector or renaming an
|
|
/// existing one is a one-place edit.
|
|
pub const sector = struct {
|
|
pub const technology = "Technology";
|
|
pub const communication_services = "Communication Services";
|
|
pub const consumer_cyclical = "Consumer Cyclical";
|
|
pub const consumer_defensive = "Consumer Defensive";
|
|
pub const healthcare = "Healthcare";
|
|
pub const financial_services = "Financial Services";
|
|
pub const energy = "Energy";
|
|
pub const industrials = "Industrials";
|
|
pub const basic_materials = "Basic Materials";
|
|
pub const real_estate = "Real Estate";
|
|
pub const utilities = "Utilities";
|
|
};
|
|
|
|
/// Country-code-to-geo-bucket lookup. Producers (Wikidata today,
|
|
/// others tomorrow) hand us ISO-3166 alpha-2 codes via the
|
|
/// `ClassificationRecord.country` field; we map them to the geo
|
|
/// taxonomy (`geo.us` / `geo.developed` / `geo.emerging` /
|
|
/// `geo.unknown`).
|
|
///
|
|
/// MSCI conventions used as the developed/emerging split. Taiwan
|
|
/// and South Korea are MSCI-emerging despite FTSE classifying
|
|
/// them developed. Israel is MSCI-developed (upgraded 2010).
|
|
/// Canada is folded into International Developed (some users
|
|
/// prefer separate Canada bucket; override in `metadata.srf`).
|
|
const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
|
|
// United States
|
|
.{ "US", geo.us },
|
|
// Alpha-3 fallback for entries that use the longer form.
|
|
.{ "USA", geo.us },
|
|
|
|
// International Developed - Europe ex-CIS
|
|
.{ "GB", geo.developed },
|
|
.{ "DE", geo.developed },
|
|
.{ "FR", geo.developed },
|
|
.{ "NL", geo.developed },
|
|
.{ "CH", geo.developed },
|
|
.{ "SE", geo.developed },
|
|
.{ "DK", geo.developed },
|
|
.{ "NO", geo.developed },
|
|
.{ "FI", geo.developed },
|
|
.{ "IT", geo.developed },
|
|
.{ "ES", geo.developed },
|
|
.{ "BE", geo.developed },
|
|
.{ "AT", geo.developed },
|
|
.{ "IE", geo.developed },
|
|
.{ "LU", geo.developed },
|
|
.{ "PT", geo.developed },
|
|
.{ "GR", geo.developed },
|
|
.{ "IS", geo.developed },
|
|
|
|
// International Developed - Asia-Pacific + Israel + Canada
|
|
.{ "JP", geo.developed },
|
|
.{ "AU", geo.developed },
|
|
.{ "NZ", geo.developed },
|
|
.{ "SG", geo.developed },
|
|
.{ "HK", geo.developed },
|
|
.{ "IL", geo.developed },
|
|
.{ "CA", geo.developed },
|
|
|
|
// Emerging Markets (MSCI)
|
|
.{ "CN", geo.emerging },
|
|
.{ "TW", geo.emerging },
|
|
.{ "KR", geo.emerging },
|
|
.{ "IN", geo.emerging },
|
|
.{ "BR", geo.emerging },
|
|
.{ "MX", geo.emerging },
|
|
.{ "RU", geo.emerging },
|
|
.{ "TR", geo.emerging },
|
|
.{ "ZA", geo.emerging },
|
|
.{ "TH", geo.emerging },
|
|
.{ "MY", geo.emerging },
|
|
.{ "ID", geo.emerging },
|
|
.{ "PH", geo.emerging },
|
|
.{ "VN", geo.emerging },
|
|
.{ "AR", geo.emerging },
|
|
.{ "CL", geo.emerging },
|
|
.{ "CO", geo.emerging },
|
|
.{ "PE", geo.emerging },
|
|
.{ "EG", geo.emerging },
|
|
});
|
|
|
|
/// Map an ISO-3166 alpha-2 country code to one of the geo
|
|
/// buckets. Null/empty input or an unknown code returns
|
|
/// `geo.unknown` so the user can override in `metadata.srf`.
|
|
pub fn geoFor(iso2: ?[]const u8) []const u8 {
|
|
const code = iso2 orelse return geo.unknown;
|
|
if (code.len == 0) return geo.unknown;
|
|
return country_to_geo.get(code) orelse geo.unknown;
|
|
}
|
|
|
|
test "geoFor maps known ISO-3166 codes to bucket" {
|
|
try std.testing.expectEqualStrings(geo.us, geoFor("US"));
|
|
try std.testing.expectEqualStrings(geo.us, geoFor("USA"));
|
|
try std.testing.expectEqualStrings(geo.developed, geoFor("GB"));
|
|
try std.testing.expectEqualStrings(geo.developed, geoFor("DE"));
|
|
try std.testing.expectEqualStrings(geo.developed, geoFor("CA"));
|
|
try std.testing.expectEqualStrings(geo.developed, geoFor("IL"));
|
|
try std.testing.expectEqualStrings(geo.emerging, geoFor("CN"));
|
|
try std.testing.expectEqualStrings(geo.emerging, geoFor("TW"));
|
|
try std.testing.expectEqualStrings(geo.emerging, geoFor("KR"));
|
|
}
|
|
|
|
test "geoFor returns Unknown for null/empty/unmapped" {
|
|
try std.testing.expectEqualStrings(geo.unknown, geoFor(null));
|
|
try std.testing.expectEqualStrings(geo.unknown, geoFor(""));
|
|
try std.testing.expectEqualStrings(geo.unknown, geoFor("ZZ")); // unassigned ISO-2
|
|
try std.testing.expectEqualStrings(geo.unknown, geoFor("XX"));
|
|
}
|
|
|
|
test "geo bucket labels are stable strings (not byte copies)" {
|
|
// Callers stash these in HashMap keys without duping.
|
|
// Verify the literal-pointer property holds across calls.
|
|
try std.testing.expectEqual(@intFromPtr(geo.us.ptr), @intFromPtr(geoFor("US").ptr));
|
|
try std.testing.expectEqual(@intFromPtr(geo.developed.ptr), @intFromPtr(geoFor("GB").ptr));
|
|
try std.testing.expectEqual(@intFromPtr(geo.emerging.ptr), @intFromPtr(geoFor("CN").ptr));
|
|
try std.testing.expectEqual(@intFromPtr(geo.unknown.ptr), @intFromPtr(geoFor(null).ptr));
|
|
}
|
|
|
|
// ── Title-keyword inference ──────────────────────────────────
|
|
//
|
|
// Pure functions over a fund/security title string. Used by
|
|
// `service.getClassification` to populate the sector / geo of a
|
|
// `ClassificationRecord` when Wikidata didn't carry one and the
|
|
// EDGAR ticker-map fallback fired. Lives here (not in any
|
|
// provider) because the inference is provider-agnostic and
|
|
// shares the canonical sector/geo taxonomy declared above.
|
|
|
|
fn titleContainsAny(haystack: []const u8, needles: []const []const u8) bool {
|
|
for (needles) |needle| {
|
|
if (std.mem.indexOf(u8, haystack, needle) != null) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Lowercase the title into a stack buffer for case-insensitive
|
|
/// keyword matching. Truncates titles longer than the buffer
|
|
/// (returns null) - real fund names easily fit in 256 bytes.
|
|
fn lowercaseTitle(buf: []u8, title: []const u8) ?[]const u8 {
|
|
if (title.len > buf.len) return null;
|
|
return std.ascii.lowerString(buf[0..title.len], title);
|
|
}
|
|
|
|
/// Infer a GICS sector from a fund's title. Returns null when
|
|
/// no unambiguous keyword match - caller falls back to whatever
|
|
/// sector data the upstream source provided (typically null).
|
|
///
|
|
/// Conservative keyword set: matches only words that map
|
|
/// unambiguously to a single GICS sector. "Income" / "Dividend"
|
|
/// / "Value" / "Growth" / "Momentum" / "Total" / "Equal Weight"
|
|
/// / "International" / "Emerging" don't appear here - they
|
|
/// describe the screening methodology or geo, not the sector.
|
|
///
|
|
/// Reuses the `sector` constants above so the inference taxonomy
|
|
/// stays in lock-step with the canonicalizer.
|
|
pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
|
const t = title orelse return null;
|
|
if (t.len == 0) return null;
|
|
|
|
var buf: [256]u8 = undefined;
|
|
const lc = lowercaseTitle(&buf, t) orelse return null;
|
|
|
|
// Order matters: more-specific keywords come first within
|
|
// each sector. "Health care" before "care" (irrelevant
|
|
// example), "semiconductor" before generic "tech" (which we
|
|
// don't include - too broad).
|
|
|
|
// Healthcare. "Health care" with space (XLV title), "healthcare"
|
|
// (one word), "biotech", "pharmaceutical".
|
|
if (titleContainsAny(lc, &.{ "health care", "healthcare", "biotech", "pharmaceutical", "medical" })) {
|
|
return sector.healthcare;
|
|
}
|
|
|
|
// Technology. Specific terms only - "tech" alone is too
|
|
// broad (matches "biotech", "fintech", "edtech" - all
|
|
// sector-mixing).
|
|
if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) {
|
|
return sector.technology;
|
|
}
|
|
|
|
// Financial Services. "Financial" is fairly specific in
|
|
// fund-name conventions ("Financial Select Sector SPDR",
|
|
// "Vanguard Financials ETF").
|
|
if (titleContainsAny(lc, &.{ "financial", "bank" })) {
|
|
return sector.financial_services;
|
|
}
|
|
|
|
// Energy. "Energy" alone is mostly unambiguous in fund
|
|
// conventions; pair with "oil" / "gas" for redundancy.
|
|
if (titleContainsAny(lc, &.{ "energy", "oil & gas", "oil and gas", "petroleum" })) {
|
|
return sector.energy;
|
|
}
|
|
|
|
// Real Estate / REITs.
|
|
if (titleContainsAny(lc, &.{ "real estate", "reit" })) {
|
|
return sector.real_estate;
|
|
}
|
|
|
|
// Utilities. "Utilities" alone is unambiguous.
|
|
if (titleContainsAny(lc, &.{"utilities"})) {
|
|
return sector.utilities;
|
|
}
|
|
|
|
// Consumer Discretionary / Cyclical. Match the explicit
|
|
// labels - "consumer" alone is ambiguous (could be
|
|
// discretionary or staples).
|
|
if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) {
|
|
return sector.consumer_cyclical;
|
|
}
|
|
|
|
// Consumer Staples / Defensive.
|
|
if (titleContainsAny(lc, &.{ "consumer staples", "consumer defensive" })) {
|
|
return sector.consumer_defensive;
|
|
}
|
|
|
|
// Industrials. "Industrial" is more reliable than
|
|
// "industrials" because some fund names use the singular
|
|
// ("Industrial Select Sector SPDR").
|
|
if (titleContainsAny(lc, &.{ "industrial", "aerospace", "defense" })) {
|
|
return sector.industrials;
|
|
}
|
|
|
|
// Basic Materials.
|
|
if (titleContainsAny(lc, &.{ "materials", "mining", "miners", "metals" })) {
|
|
return sector.basic_materials;
|
|
}
|
|
|
|
// Communication Services. "Communication" / "Telecom"
|
|
// unambiguous.
|
|
if (titleContainsAny(lc, &.{ "communication", "telecom", "media" })) {
|
|
return sector.communication_services;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Infer a geo bucket from a fund's title. Returns null when
|
|
/// the title doesn't carry an unambiguous international/emerging
|
|
/// keyword - caller keeps whatever default they have (typically
|
|
/// US for SEC-filed funds).
|
|
///
|
|
/// More important than sector inference: a default `geo::US` is
|
|
/// *factually wrong* for international funds (FRDM holds
|
|
/// emerging-market equities, not US), so this fix tightens
|
|
/// portfolio-level geographic-exposure reporting.
|
|
pub fn inferGeoFromTitle(title: ?[]const u8) ?[]const u8 {
|
|
const t = title orelse return null;
|
|
if (t.len == 0) return null;
|
|
|
|
var buf: [256]u8 = undefined;
|
|
const lc = lowercaseTitle(&buf, t) orelse return null;
|
|
|
|
// Emerging markets first - most specific. "Emerging" alone
|
|
// is rare in non-EM contexts in fund-name conventions.
|
|
// "Frontier" likewise is conventionally only used for
|
|
// frontier markets in fund titles.
|
|
if (titleContainsAny(lc, &.{ "emerging market", "emerging markets", "frontier market", "frontier markets", "frontier" })) {
|
|
return geo.emerging;
|
|
}
|
|
|
|
// International Developed. "International" / "Intl" /
|
|
// "ex-US" / "World ex US" / "Developed Markets" /
|
|
// specific developed-market regions.
|
|
//
|
|
// False-positive risk: a hypothetical "Vanguard Total
|
|
// International + US Equity Fund" would mis-tag here. None
|
|
// of the user's current portfolio holds such a hybrid
|
|
// fund. If one ever shows up, it'll get flagged in the
|
|
// diff-against-old-metadata.srf review and can be
|
|
// hand-corrected.
|
|
if (titleContainsAny(lc, &.{ "international", " intl", "ex-us", "ex us", "world ex", "developed market", "developed markets" })) {
|
|
return geo.developed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
test "inferSectorFromTitle: null/empty -> null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferSectorFromTitle(null));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferSectorFromTitle(""));
|
|
}
|
|
|
|
test "inferSectorFromTitle: technology keywords" {
|
|
try std.testing.expectEqual(@as(?[]const u8, sector.technology), inferSectorFromTitle("iShares Semiconductor ETF"));
|
|
try std.testing.expectEqual(@as(?[]const u8, sector.technology), inferSectorFromTitle("Vanguard Software ETF"));
|
|
}
|
|
|
|
test "inferSectorFromTitle: healthcare keywords" {
|
|
try std.testing.expectEqual(@as(?[]const u8, sector.healthcare), inferSectorFromTitle("Health Care Select Sector SPDR"));
|
|
try std.testing.expectEqual(@as(?[]const u8, sector.healthcare), inferSectorFromTitle("iShares Biotech ETF"));
|
|
}
|
|
|
|
test "inferSectorFromTitle: ambiguous title -> null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferSectorFromTitle("Vanguard Total Stock Market"));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferSectorFromTitle("SPDR S&P 500"));
|
|
}
|
|
|
|
test "inferGeoFromTitle: null/empty -> null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferGeoFromTitle(null));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferGeoFromTitle(""));
|
|
}
|
|
|
|
test "inferGeoFromTitle: emerging markets" {
|
|
try std.testing.expectEqual(@as(?[]const u8, geo.emerging), inferGeoFromTitle("Freedom 100 Emerging Markets ETF"));
|
|
try std.testing.expectEqual(@as(?[]const u8, geo.emerging), inferGeoFromTitle("iShares Frontier Markets"));
|
|
}
|
|
|
|
test "inferGeoFromTitle: international developed" {
|
|
try std.testing.expectEqual(@as(?[]const u8, geo.developed), inferGeoFromTitle("Vanguard FTSE Developed Markets"));
|
|
try std.testing.expectEqual(@as(?[]const u8, geo.developed), inferGeoFromTitle("iShares MSCI EAFE International"));
|
|
try std.testing.expectEqual(@as(?[]const u8, geo.developed), inferGeoFromTitle("Vanguard Total World ex-US"));
|
|
}
|
|
|
|
test "inferGeoFromTitle: no match -> null" {
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferGeoFromTitle("SPDR S&P 500"));
|
|
try std.testing.expectEqual(@as(?[]const u8, null), inferGeoFromTitle("iShares Semiconductor ETF"));
|
|
}
|