From f637740c1384060e6f549da137c3dc2acb65cde9 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 11 Mar 2026 15:09:56 -0700 Subject: [PATCH] allow setting price ratio --- src/analytics/risk.zig | 69 +++++++++++++++++++++++++++++++++++++++- src/cache/store.zig | 40 +++++++++++++++++++++++ src/models/portfolio.zig | 46 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 826f688..62a7756 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -159,7 +159,11 @@ pub fn portfolioSummary( for (positions) |pos| { if (pos.shares <= 0) continue; - const price = prices.get(pos.symbol) orelse continue; + const raw_price = prices.get(pos.symbol) orelse continue; + // Only apply price_ratio to live/fetched prices. Manual/fallback prices + // (avg_cost) are already in the correct terms for the share class. + const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false; + const price = if (is_manual) raw_price else raw_price * (pos.price_ratio orelse 1.0); const mv = pos.shares * price; total_value += mv; total_cost += pos.total_cost; @@ -612,3 +616,66 @@ test "buildFallbackPrices" { try std.testing.expect(manual.contains("CUSIP1")); try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01); } + +test "portfolioSummary applies price_ratio" { + const Position = @import("../models/portfolio.zig").Position; + const alloc = std.testing.allocator; + + var positions = [_]Position{ + // VTTHX with price_ratio 5.185 (institutional share class) + .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 }, + // Regular stock, no ratio + .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("VTTHX", 27.78); // investor class price + try prices.put("AAPL", 175.0); + + var summary = try portfolioSummary(alloc, &positions, prices, null); + defer summary.deinit(alloc); + + try std.testing.expectEqual(@as(usize, 2), summary.allocations.len); + + for (summary.allocations) |a| { + if (std.mem.eql(u8, a.symbol, "VTTHX")) { + // Price should be adjusted: 27.78 * 5.185 ≈ 144.04 + try std.testing.expectApproxEqAbs(@as(f64, 144.04), a.current_price, 0.1); + // Market value: 100 * 144.04 ≈ 14404 + try std.testing.expectApproxEqAbs(@as(f64, 14404.0), a.market_value, 10.0); + } else { + // AAPL: no ratio, price unchanged + try std.testing.expectApproxEqAbs(@as(f64, 175.0), a.current_price, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 1750.0), a.market_value, 0.01); + } + } +} + +test "portfolioSummary skips price_ratio for manual/fallback prices" { + const Position = @import("../models/portfolio.zig").Position; + const alloc = std.testing.allocator; + + var positions = [_]Position{ + // VTTHX with price_ratio — but price is a fallback (avg_cost), already institutional + .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("VTTHX", 140.0); // fallback: avg_cost, already institutional + + // Mark VTTHX as manual/fallback + var manual = std.StringHashMap(void).init(alloc); + defer manual.deinit(); + try manual.put("VTTHX", {}); + + var summary = try portfolioSummary(alloc, &positions, prices, manual); + defer summary.deinit(alloc); + + try std.testing.expectEqual(@as(usize, 1), summary.allocations.len); + + // Price should NOT be multiplied by ratio — it's already institutional + try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01); +} diff --git a/src/cache/store.zig b/src/cache/store.zig index ae07e9b..722bed2 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -868,3 +868,43 @@ test "portfolio: cash lots without symbol get CASH placeholder" { // Stock lot: symbol present try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol); } + +test "portfolio: price_ratio round-trip" { + const allocator = std.testing.allocator; + const data = + \\#!srfv1 + \\symbol::02315N600,shares:num:100,open_date::2024-01-15,open_price:num:140.00,ticker::VTTHX,price_ratio:num:5.185,note::VANGUARD TARGET 2035 + \\symbol::AAPL,shares:num:10,open_date::2024-03-01,open_price:num:150.00 + \\ + ; + + var portfolio = try deserializePortfolio(allocator, data); + defer portfolio.deinit(); + + try std.testing.expectEqual(@as(usize, 2), portfolio.lots.len); + + // CUSIP lot with price_ratio and ticker + try std.testing.expectEqualStrings("02315N600", portfolio.lots[0].symbol); + try std.testing.expectEqualStrings("VTTHX", portfolio.lots[0].ticker.?); + try std.testing.expectEqualStrings("VTTHX", portfolio.lots[0].priceSymbol()); + try std.testing.expect(portfolio.lots[0].price_ratio != null); + try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio.?, 0.001); + try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?); + + // Regular lot — no price_ratio + try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol); + try std.testing.expect(portfolio.lots[1].price_ratio == null); + try std.testing.expect(portfolio.lots[1].ticker == null); + + // Round-trip: serialize and deserialize again + const reserialized = try serializePortfolio(allocator, portfolio.lots); + defer allocator.free(reserialized); + + var portfolio2 = try deserializePortfolio(allocator, reserialized); + defer portfolio2.deinit(); + + try std.testing.expectEqual(@as(usize, 2), portfolio2.lots.len); + try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio2.lots[0].price_ratio.?, 0.001); + try std.testing.expectEqualStrings("VTTHX", portfolio2.lots[0].ticker.?); + try std.testing.expect(portfolio2.lots[1].price_ratio == null); +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index dd1e5c8..f3d32e1 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -62,6 +62,11 @@ pub const Lot = struct { price: ?f64 = null, /// Date of the manual price (for display/staleness tracking). price_date: ?Date = null, + /// Price ratio for institutional share classes. When set, the fetched price + /// (from the `ticker` symbol) is multiplied by this ratio to get the actual + /// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional + /// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185. + price_ratio: ?f64 = null, /// The symbol to use for price fetching (ticker if set, else symbol). pub fn priceSymbol(self: Lot) []const u8 { @@ -118,6 +123,13 @@ pub const Position = struct { account: []const u8 = "", /// Note from the first lot (e.g. "VANGUARD TARGET 2035"). note: ?[]const u8 = null, + /// Price ratio for institutional share classes (from lot). + /// NOTE: If lots with different price_ratios (or a mix of ratio/no-ratio) + /// share the same priceSymbol(), the position grouping would be incorrect. + /// Currently positions() takes the ratio from the first lot that has one. + /// Supporting dual-holding of investor + institutional shares of the same + /// ticker would require a different grouping key in positions(). + price_ratio: ?f64 = null, }; /// A portfolio is a collection of lots. @@ -228,6 +240,7 @@ pub const Portfolio = struct { .realized_gain_loss = 0, .account = lot.account orelse "", .note = lot.note, + .price_ratio = lot.price_ratio, }; } else { // Track account: if lots have different accounts, mark as "Multiple" @@ -236,6 +249,10 @@ pub const Portfolio = struct { if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) { entry.value_ptr.account = "Multiple"; } + // Propagate price_ratio from the first lot that has one + if (entry.value_ptr.price_ratio == null and lot.price_ratio != null) { + entry.value_ptr.price_ratio = lot.price_ratio; + } } if (lot.isOpen()) { entry.value_ptr.shares += lot.shares; @@ -499,3 +516,32 @@ test "Portfolio watchSymbols" { defer allocator.free(watch); try std.testing.expectEqual(@as(usize, 2), watch.len); } + +test "positions propagates price_ratio from lot" { + const allocator = std.testing.allocator; + + var lots = [_]Lot{ + // Two institutional lots for the same CUSIP, both with ticker alias and price_ratio + .{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 }, + .{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 }, + // Regular stock lot — no price_ratio + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 }, + }; + + var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + const pos = try portfolio.positions(allocator); + defer allocator.free(pos); + + try std.testing.expectEqual(@as(usize, 2), pos.len); + + for (pos) |p| { + if (std.mem.eql(u8, p.symbol, "VTTHX")) { + try std.testing.expectApproxEqAbs(@as(f64, 150.0), p.shares, 0.01); + try std.testing.expect(p.price_ratio != null); + try std.testing.expectApproxEqAbs(@as(f64, 5.185), p.price_ratio.?, 0.001); + } else { + try std.testing.expectEqualStrings("AAPL", p.symbol); + try std.testing.expect(p.price_ratio == null); + } + } +}