351 lines
13 KiB
Zig
351 lines
13 KiB
Zig
/// 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::<NAME>,tax_type::<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);
|
|
}
|