allow setting price ratio

This commit is contained in:
Emil Lerch 2026-03-11 15:09:56 -07:00
parent 195b660f61
commit f637740c13
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 154 additions and 1 deletions

View file

@ -159,7 +159,11 @@ pub fn portfolioSummary(
for (positions) |pos| { for (positions) |pos| {
if (pos.shares <= 0) continue; 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; const mv = pos.shares * price;
total_value += mv; total_value += mv;
total_cost += pos.total_cost; total_cost += pos.total_cost;
@ -612,3 +616,66 @@ test "buildFallbackPrices" {
try std.testing.expect(manual.contains("CUSIP1")); try std.testing.expect(manual.contains("CUSIP1"));
try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01); 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);
}

40
src/cache/store.zig vendored
View file

@ -868,3 +868,43 @@ test "portfolio: cash lots without symbol get CASH placeholder" {
// Stock lot: symbol present // Stock lot: symbol present
try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol); 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);
}

View file

@ -62,6 +62,11 @@ pub const Lot = struct {
price: ?f64 = null, price: ?f64 = null,
/// Date of the manual price (for display/staleness tracking). /// Date of the manual price (for display/staleness tracking).
price_date: ?Date = null, 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). /// The symbol to use for price fetching (ticker if set, else symbol).
pub fn priceSymbol(self: Lot) []const u8 { pub fn priceSymbol(self: Lot) []const u8 {
@ -118,6 +123,13 @@ pub const Position = struct {
account: []const u8 = "", account: []const u8 = "",
/// Note from the first lot (e.g. "VANGUARD TARGET 2035"). /// Note from the first lot (e.g. "VANGUARD TARGET 2035").
note: ?[]const u8 = null, 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. /// A portfolio is a collection of lots.
@ -228,6 +240,7 @@ pub const Portfolio = struct {
.realized_gain_loss = 0, .realized_gain_loss = 0,
.account = lot.account orelse "", .account = lot.account orelse "",
.note = lot.note, .note = lot.note,
.price_ratio = lot.price_ratio,
}; };
} else { } else {
// Track account: if lots have different accounts, mark as "Multiple" // 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)) { if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) {
entry.value_ptr.account = "Multiple"; 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()) { if (lot.isOpen()) {
entry.value_ptr.shares += lot.shares; entry.value_ptr.shares += lot.shares;
@ -499,3 +516,32 @@ test "Portfolio watchSymbols" {
defer allocator.free(watch); defer allocator.free(watch);
try std.testing.expectEqual(@as(usize, 2), watch.len); 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);
}
}
}