fixes for analysis processing
This commit is contained in:
parent
8c9579d4e5
commit
e6bd38d4c3
2 changed files with 127 additions and 5 deletions
8
TODO.md
8
TODO.md
|
|
@ -1,5 +1,13 @@
|
||||||
# Future Work
|
# 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
|
## Upgrade to 0.16.0
|
||||||
|
|
||||||
Pending dependencies:
|
Pending dependencies:
|
||||||
|
|
|
||||||
|
|
@ -252,11 +252,17 @@ pub fn analyzePortfolio(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build symbol -> current_price lookup from allocations (for lot-level valuation)
|
// Build symbol -> (current_price, price_ratio) lookup from allocations.
|
||||||
var price_lookup = std.StringHashMap(f64).init(allocator);
|
// 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();
|
defer price_lookup.deinit();
|
||||||
for (allocations) |alloc| {
|
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).
|
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
|
||||||
|
|
@ -269,8 +275,12 @@ pub fn analyzePortfolio(
|
||||||
const acct = lot.account orelse continue;
|
const acct = lot.account orelse continue;
|
||||||
const value: f64 = switch (lot.security_type) {
|
const value: f64 = switch (lot.security_type) {
|
||||||
.stock => blk: {
|
.stock => blk: {
|
||||||
const price = price_lookup.get(lot.priceSymbol()) orelse lot.open_price;
|
if (price_lookup.get(lot.priceSymbol())) |entry| {
|
||||||
break :blk lot.shares * price;
|
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,
|
.cash => lot.shares,
|
||||||
.cd => lot.shares, // face value
|
.cd => lot.shares, // face value
|
||||||
|
|
@ -423,3 +433,107 @@ test "parseAccountsFile missing fields" {
|
||||||
defer am.deinit();
|
defer am.deinit();
|
||||||
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
|
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