From d6b45b03187a3d068b66de5f08a8aec270d3accb Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 27 Apr 2026 16:49:27 -0700 Subject: [PATCH] fixes for analysis processing --- TODO.md | 8 +++ src/analytics/analysis.zig | 124 +++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index cdc2c9f..616212a 100644 --- a/TODO.md +++ b/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: diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index f703cad..174df37 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -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); +}