allow setting price ratio
This commit is contained in:
parent
195b660f61
commit
f637740c13
3 changed files with 154 additions and 1 deletions
|
|
@ -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
40
src/cache/store.zig
vendored
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue