zfin/src/models/classification.zig

104 lines
4.2 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 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::<SYM>,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.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);
}