/// Portfolio analysis engine. /// /// Takes portfolio allocations (with market values) and classification metadata, /// produces breakdowns by asset class, sector, geographic region, account, and tax type. const std = @import("std"); const srf = @import("srf"); const Allocation = @import("valuation.zig").Allocation; const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; /// A single slice of a breakdown (e.g., "Technology" -> 25.3%) pub const BreakdownItem = struct { label: []const u8, value: f64, // dollar amount weight: f64, // fraction of total (0.0 - 1.0) }; /// Tax type classification for accounts. pub const TaxType = enum { taxable, roth, traditional, hsa, pub fn label(self: TaxType) []const u8 { return switch (self) { .taxable => "Taxable", .roth => "Roth (Post-Tax)", .traditional => "Traditional (Pre-Tax)", .hsa => "HSA (Triple Tax-Free)", }; } }; /// Account tax type classification entry, parsed from accounts.srf. pub const AccountTaxEntry = struct { account: []const u8, tax_type: TaxType, }; /// Parsed account metadata. pub const AccountMap = struct { entries: []AccountTaxEntry, allocator: std.mem.Allocator, pub fn deinit(self: *AccountMap) void { for (self.entries) |e| { self.allocator.free(e.account); } self.allocator.free(self.entries); } /// Look up the tax type label for a given account name. pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 { for (self.entries) |e| { if (std.mem.eql(u8, e.account, account)) { return e.tax_type.label(); } } return "Unknown"; } }; /// Parse an accounts.srf file into an AccountMap. /// Each record has: account::,tax_type:: pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { var entries = std.ArrayList(AccountTaxEntry).empty; errdefer { for (entries.items) |e| { allocator.free(e.account); } 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(AccountTaxEntry) catch continue; try entries.append(allocator, .{ .account = try allocator.dupe(u8, entry.account), .tax_type = entry.tax_type, }); } return .{ .entries = try entries.toOwnedSlice(allocator), .allocator = allocator, }; } /// Complete portfolio analysis result. pub const AnalysisResult = struct { /// Breakdown by asset class (US Large Cap, Bonds, Cash & CDs, etc.) asset_class: []BreakdownItem, /// Breakdown by sector (Technology, Healthcare, etc.) -- equities only sector: []BreakdownItem, /// Breakdown by geographic region (US, International, etc.) geo: []BreakdownItem, /// Breakdown by account name account: []BreakdownItem, /// Breakdown by tax type (Taxable, Roth, Traditional, HSA) tax_type: []BreakdownItem, /// Positions not covered by classification metadata unclassified: []const []const u8, /// Total portfolio value used as denominator total_value: f64, pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void { allocator.free(self.asset_class); allocator.free(self.sector); allocator.free(self.geo); allocator.free(self.account); allocator.free(self.tax_type); allocator.free(self.unclassified); } }; /// Compute portfolio analysis from allocations and classification metadata. /// `allocations` are the stock/ETF positions with market values. /// `classifications` is the metadata file data. /// `portfolio` is the full portfolio (for cash/CD/illiquid totals). /// `account_map` is optional account tax type metadata. pub fn analyzePortfolio( allocator: std.mem.Allocator, allocations: []const Allocation, classifications: ClassificationMap, portfolio: Portfolio, total_portfolio_value: f64, account_map: ?AccountMap, ) !AnalysisResult { // Accumulators: label -> dollar amount var ac_map = std.StringHashMap(f64).init(allocator); defer ac_map.deinit(); var sector_map = std.StringHashMap(f64).init(allocator); defer sector_map.deinit(); var geo_map = std.StringHashMap(f64).init(allocator); defer geo_map.deinit(); var acct_map = std.StringHashMap(f64).init(allocator); defer acct_map.deinit(); var tax_map = std.StringHashMap(f64).init(allocator); defer tax_map.deinit(); var unclassified_list = std.ArrayList([]const u8).empty; errdefer unclassified_list.deinit(allocator); // Process each equity allocation (for asset class, sector, geo, unclassified) for (allocations) |alloc| { const mv = alloc.market_value; if (mv <= 0) continue; // Find classification entries for this symbol // Try both the raw symbol and display_symbol var found = false; for (classifications.entries) |entry| { if (std.mem.eql(u8, entry.symbol, alloc.symbol) or std.mem.eql(u8, entry.symbol, alloc.display_symbol)) { found = true; const frac = entry.pct / 100.0; const portion = mv * frac; if (entry.asset_class) |ac| { const prev = ac_map.get(ac) orelse 0; ac_map.put(ac, prev + portion) catch {}; } if (entry.sector) |s| { const prev = sector_map.get(s) orelse 0; sector_map.put(s, prev + portion) catch {}; } if (entry.geo) |g| { const prev = geo_map.get(g) orelse 0; geo_map.put(g, prev + portion) catch {}; } } } if (!found) { try unclassified_list.append(allocator, alloc.display_symbol); } } // Build symbol -> current_price lookup from allocations (for lot-level valuation) var price_lookup = std.StringHashMap(f64).init(allocator); defer price_lookup.deinit(); for (allocations) |alloc| { price_lookup.put(alloc.symbol, alloc.current_price) catch {}; } // Account breakdown from individual lots (avoids "Multiple" aggregation issue) for (portfolio.lots) |lot| { if (!lot.isOpen()) continue; const acct = lot.account orelse continue; const value: f64 = switch (lot.security_type) { .stock => blk: { const price = price_lookup.get(lot.priceSymbol()) orelse lot.open_price; break :blk lot.shares * price; }, .cash => lot.shares, .cd => lot.shares, // face value .option => @abs(lot.shares) * lot.open_price, .illiquid, .watch => continue, }; const prev = acct_map.get(acct) orelse 0; acct_map.put(acct, prev + value) catch {}; } // Add non-stock asset classes (combine Cash + CDs) const cash_total = portfolio.totalCash(); const cd_total = portfolio.totalCdFaceValue(); const cash_cd_total = cash_total + cd_total; if (cash_cd_total > 0) { const prev = ac_map.get("Cash & CDs") orelse 0; ac_map.put("Cash & CDs", prev + cash_cd_total) catch {}; const gprev = geo_map.get("US") orelse 0; geo_map.put("US", gprev + cash_cd_total) catch {}; } const opt_total = portfolio.totalOptionCost(); if (opt_total > 0) { const prev = ac_map.get("Options") orelse 0; ac_map.put("Options", prev + opt_total) catch {}; } // Tax type breakdown: map each account's total to its tax type if (account_map) |am| { var acct_iter = acct_map.iterator(); while (acct_iter.next()) |kv| { const tt = am.taxTypeFor(kv.key_ptr.*); const prev = tax_map.get(tt) orelse 0; tax_map.put(tt, prev + kv.value_ptr.*) catch {}; } } // Convert maps to sorted slices const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0; return .{ .asset_class = try mapToSortedBreakdown(allocator, ac_map, total), .sector = try mapToSortedBreakdown(allocator, sector_map, total), .geo = try mapToSortedBreakdown(allocator, geo_map, total), .account = try mapToSortedBreakdown(allocator, acct_map, total), .tax_type = try mapToSortedBreakdown(allocator, tax_map, total), .unclassified = try unclassified_list.toOwnedSlice(allocator), .total_value = total_portfolio_value, }; } /// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value). fn mapToSortedBreakdown( allocator: std.mem.Allocator, map: std.StringHashMap(f64), total: f64, ) ![]BreakdownItem { var items = std.ArrayList(BreakdownItem).empty; errdefer items.deinit(allocator); var iter = map.iterator(); while (iter.next()) |kv| { try items.append(allocator, .{ .label = kv.key_ptr.*, .value = kv.value_ptr.*, .weight = kv.value_ptr.* / total, }); } // Sort descending by value std.mem.sort(BreakdownItem, items.items, {}, struct { fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool { return a.value > b.value; } }.f); return items.toOwnedSlice(allocator); } test "parseAccountsFile" { const data = \\#!srfv1 \\account::Emil Roth,tax_type::roth \\account::Joint trust,tax_type::taxable \\account::Fidelity Emil HSA,tax_type::hsa ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 3), am.entries.len); try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Emil Roth")); try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Joint trust")); try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Emil HSA")); try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); } test "TaxType.label" { try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label()); try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label()); try std.testing.expectEqualStrings("Traditional (Pre-Tax)", TaxType.traditional.label()); try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", TaxType.hsa.label()); } test "mapToSortedBreakdown" { const allocator = std.testing.allocator; var map = std.StringHashMap(f64).init(allocator); defer map.deinit(); try map.put("Technology", 50_000); try map.put("Healthcare", 30_000); try map.put("Energy", 20_000); const total = 100_000.0; const breakdown = try mapToSortedBreakdown(allocator, map, total); defer allocator.free(breakdown); try std.testing.expectEqual(@as(usize, 3), breakdown.len); // Should be sorted descending by value try std.testing.expectEqualStrings("Technology", breakdown[0].label); try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001); try std.testing.expectEqualStrings("Healthcare", breakdown[1].label); try std.testing.expectEqualStrings("Energy", breakdown[2].label); } test "mapToSortedBreakdown empty" { const allocator = std.testing.allocator; var map = std.StringHashMap(f64).init(allocator); defer map.deinit(); const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0); defer allocator.free(breakdown); try std.testing.expectEqual(@as(usize, 0), breakdown.len); } test "parseAccountsFile empty" { const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, "#!srfv1\n"); defer am.deinit(); try std.testing.expectEqual(@as(usize, 0), am.entries.len); } test "parseAccountsFile missing fields" { // Line with only account but no tax_type -> skipped via Record.to() error. // Override log level to suppress expected srf log.err output that // would otherwise cause the test runner to report failure. const prev_level = std.testing.log_level; std.testing.log_level = .err; defer std.testing.log_level = prev_level; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n"); defer am.deinit(); try std.testing.expectEqual(@as(usize, 0), am.entries.len); }