fixes for analysis processing

This commit is contained in:
Emil Lerch 2026-04-27 16:49:27 -07:00
parent a2271e3582
commit d6b45b0318
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 127 additions and 5 deletions

View file

@ -1,5 +1,13 @@
# Future Work
## Analysis account/asset-class total mismatch
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly
more than "Asset Class" (~0.6% error). Likely a discrepancy between how the lot-level
account loop values cash, CDs, or options vs how the asset-class section computes them
via `portfolio.totalCash()` / `totalCdFaceValue()`. Low priority — the per-account
values themselves are correct after the price_ratio fix.
## Upgrade to 0.16.0
Pending dependencies:

View file

@ -252,11 +252,17 @@ pub fn analyzePortfolio(
}
}
// Build symbol -> current_price lookup from allocations (for lot-level valuation)
var price_lookup = std.StringHashMap(f64).init(allocator);
// Build symbol -> (current_price, price_ratio) lookup from allocations.
// For unmerged allocations, current_price already includes price_ratio (preadjusted).
// For merged allocations, current_price is the base-ticker price (not preadjusted).
const PriceEntry = struct { price: f64, is_preadjusted: bool };
var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
defer price_lookup.deinit();
for (allocations) |alloc| {
price_lookup.put(alloc.symbol, alloc.current_price) catch {};
price_lookup.put(alloc.symbol, .{
.price = alloc.current_price,
.is_preadjusted = alloc.price_ratio != 1.0,
}) catch {};
}
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
@ -269,8 +275,12 @@ pub fn analyzePortfolio(
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;
if (price_lookup.get(lot.priceSymbol())) |entry| {
break :blk lot.marketValue(entry.price, entry.is_preadjusted);
} else {
// Fallback to open_price (already in lot-specific terms)
break :blk lot.shares * lot.open_price;
}
},
.cash => lot.shares,
.cd => lot.shares, // face value
@ -423,3 +433,107 @@ test "parseAccountsFile missing fields" {
defer am.deinit();
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}
test "account breakdown applies price_ratio" {
const allocator = std.testing.allocator;
const Lot = @import("../models/portfolio.zig").Lot;
// Three lots across two accounts:
// - Brokerage: direct SPY (ratio 1.0)
// - 401(k): CIT mapped to SPY (ratio 0.25, merged allocation)
// - 401(k): CUSIP with ticker=VTTHX (ratio 5.0, unmerged allocation)
var lots = [_]Lot{
.{
.symbol = "SPY",
.shares = 100,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 400,
.account = "Brokerage",
},
.{
.symbol = "CIT-SPY",
.shares = 500,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.ticker = "SPY",
.price_ratio = 0.25,
.account = "401(k)",
},
.{
.symbol = "CUSIP123",
.shares = 200,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 50,
.ticker = "VTTHX",
.price_ratio = 5.0,
.account = "401(k)",
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Allocations as produced by portfolioSummary + mergeAllocsBySymbol:
// SPY: merged (direct + CIT). current_price = base SPY price = 500, price_ratio = 1.0
// VTTHX: unmerged. current_price = 30 * 5.0 = 150 (already includes ratio), price_ratio = 5.0
const allocations = [_]Allocation{
.{
.symbol = "SPY",
.display_symbol = "SPY",
.shares = 225, // 100 + 500*0.25
.avg_cost = 300,
.current_price = 500, // base-ticker price (merged, ratio=1.0)
.market_value = 112_500,
.cost_basis = 67_500,
.weight = 0.789,
.unrealized_gain_loss = 45_000,
.unrealized_return = 0.667,
.price_ratio = 1.0, // merged
},
.{
.symbol = "VTTHX",
.display_symbol = "VTTHX",
.shares = 200,
.avg_cost = 50,
.current_price = 150, // already includes price_ratio (30 * 5.0)
.market_value = 30_000, // 200 * 150
.cost_basis = 10_000,
.weight = 0.211,
.unrealized_gain_loss = 20_000,
.unrealized_return = 2.0,
.price_ratio = 5.0, // unmerged, ratio preserved
},
};
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
142_500,
null,
null,
);
defer result.deinit(allocator);
// Expected account values:
// Brokerage: SPY direct, 100 shares * $500 * 1.0 = $50,000
// 401(k): CIT-SPY 500 shares * $500 * 0.25 = $62,500
// + CUSIP123 200 shares * $150 (already includes ratio) = $30,000
// = $92,500
// Total: $142,500
for (result.account) |item| {
if (std.mem.eql(u8, item.label, "Brokerage")) {
try std.testing.expectApproxEqAbs(@as(f64, 50_000), item.value, 1.0);
} else if (std.mem.eql(u8, item.label, "401(k)")) {
try std.testing.expectApproxEqAbs(@as(f64, 92_500), item.value, 1.0);
}
}
// Sum of accounts must equal total portfolio value
var account_sum: f64 = 0;
for (result.account) |item| {
account_sum += item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 142_500), account_sum, 1.0);
}