fixes for analysis processing
This commit is contained in:
parent
a2271e3582
commit
d6b45b0318
2 changed files with 127 additions and 5 deletions
8
TODO.md
8
TODO.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue