/// 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 srf = @import("srf"); /// A single classification entry for a symbol. pub const ClassificationEntry = struct { symbol: []const u8, /// 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, }; /// 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.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::,sector::,geo::,asset_class::,pct:num:

/// 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.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, .{ .alloc_strings = false }) catch return error.InvalidData; defer it.deinit(); while (try it.next()) |fields| { const entry = fields.to(ClassificationEntry) catch continue; try entries.append(allocator, .{ .symbol = try allocator.dupe(u8, entry.symbol), .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, }); } return .{ .entries = try entries.toOwnedSlice(allocator), .allocator = allocator, }; } test "parse classification file" { const data = \\#!srfv1 \\# Stock: single sector \\symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap \\ \\# Target date fund: blended \\symbol::TGT2035,asset_class::US Large Cap,pct:num:55 \\symbol::TGT2035,asset_class::Bonds,pct:num:15 \\symbol::TGT2035,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("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("US Large Cap", cm.entries[1].asset_class.?); try std.testing.expectApproxEqAbs(@as(f64, 55.0), cm.entries[1].pct, 0.01); }