fix bug with synthetic securities (with ratio) gain/loss miscalculation in rollups
This commit is contained in:
parent
6ebd944f94
commit
6dea255377
5 changed files with 427 additions and 27 deletions
|
|
@ -206,6 +206,15 @@ pub const Allocation = struct {
|
|||
/// Weighted average cost per share across all lots (cost_basis / shares).
|
||||
avg_cost: f64,
|
||||
/// Latest price from API (or manual fallback), before price_ratio adjustment.
|
||||
///
|
||||
/// POSITION-level and RAW. Never feed this into a per-lot
|
||||
/// calculation: `mergeAllocsBySymbol` folds every ratio variant of a
|
||||
/// ticker into one row with base-ticker-normalized `shares`, so a
|
||||
/// lot row multiplying its own raw shares by this price is off by
|
||||
/// exactly the lot's ratio. Per-lot display sites go through
|
||||
/// `views/portfolio_sections.zig:effectivePriceFor`. See the "Per-LOT
|
||||
/// display rows" section of the pricing-model block in
|
||||
/// `models/portfolio.zig`.
|
||||
current_price: f64,
|
||||
/// Total current value: shares * current_price * price_ratio.
|
||||
/// May be reduced by adjustForCoveredCalls for ITM sold calls
|
||||
|
|
|
|||
|
|
@ -415,13 +415,13 @@ pub fn display(
|
|||
if (!has_drip) {
|
||||
// No DRIP: show all individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
try printLotRow(as_of, out, color, lot, a.current_price, w);
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
}
|
||||
} else {
|
||||
// Show non-DRIP lots individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
try printLotRow(as_of, out, color, lot, a.current_price, w);
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -647,13 +647,21 @@ pub fn display(
|
|||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !void {
|
||||
/// Render one lot-detail row under its position row.
|
||||
///
|
||||
/// `effective_price` is the LOT's price, not its position's: pass
|
||||
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
|
||||
/// `price_ratio` and resolves `close_price`. Passing an
|
||||
/// `Allocation.current_price` straight through is the bug this
|
||||
/// parameter name exists to prevent - that price is the raw base-ticker
|
||||
/// price, so a ratio'd lot's Value and Gain/Loss come out scaled by its
|
||||
/// ratio (a 0.2387 sleeve rendered +$3.39M against a real +$433K).
|
||||
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, effective_price: f64, w: views.PositionsWidths) !void {
|
||||
const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
|
||||
const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed";
|
||||
const acct_col: []const u8 = lot.account orelse "";
|
||||
|
||||
const use_price = lot.close_price orelse current_price;
|
||||
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
|
||||
const gl = lot.effectiveShares() * (effective_price - lot.effectiveOpenPrice());
|
||||
const lot_gl_abs = if (gl >= 0) gl else -gl;
|
||||
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
|
||||
|
||||
|
|
@ -669,9 +677,16 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin
|
|||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(lot.effectiveOpenPrice()).padRight(w.price_w)});
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "", w.price_w, true); // blank current-price cell
|
||||
// Current-price cell: normally blank (the position row above
|
||||
// already shows it), but a ratio'd lot prices off its own
|
||||
// institutional NAV, so show that.
|
||||
if (views.hasOwnPrice(lot)) {
|
||||
try out.print("{f}", .{Money.from(effective_price).padRight(w.price_w)});
|
||||
} else {
|
||||
try views.writeCol(out, "", w.price_w, true);
|
||||
}
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(lot.effectiveShares() * use_price).padRight(w.value_w)});
|
||||
try out.print("{f}", .{Money.from(lot.effectiveShares() * effective_price).padRight(w.value_w)});
|
||||
try out.writeByte(' ');
|
||||
try cli.reset(out, color);
|
||||
// Colored gain/loss cell.
|
||||
|
|
@ -1325,4 +1340,54 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "1000.0") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$4.00") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "120,000") != null);
|
||||
// Ratio 1.0 -> the current-price cell stays blank, so $120.00
|
||||
// appears nowhere. (Guards the `hasOwnPrice` gate: a bare
|
||||
// "$120.00" would mean the cell got filled unconditionally.)
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$120.00") == null);
|
||||
}
|
||||
|
||||
test "printLotRow: a ratio'd lot values off its effective price and shows it" {
|
||||
// The bug this pins: the caller must pass the LOT's effective price
|
||||
// (`views.effectivePriceFor`), not the position's raw base-ticker price.
|
||||
// Here the sleeve holds 5075.077 shares of a $765.91 base ticker at
|
||||
// a 0.2387 ratio. Valuing at the raw price gives $3.89M / +$3.39M;
|
||||
// the truth is $927,824 / +$433,004.
|
||||
var lots = [_]zfin.Lot{.{
|
||||
.symbol = "AGG-LC",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 0.2386960690140873,
|
||||
.shares = 5075.077,
|
||||
.open_date = zfin.Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 97.50,
|
||||
.account = "Sample 401(k)",
|
||||
}};
|
||||
const allocs = [_]zfin.valuation.Allocation{.{
|
||||
.symbol = "BENCH",
|
||||
.display_symbol = "BENCH",
|
||||
.shares = 1211.402,
|
||||
.avg_cost = 408.47,
|
||||
.current_price = 765.91, // RAW base-ticker price
|
||||
.market_value = 927824.086,
|
||||
.cost_basis = 494820.008,
|
||||
.weight = 1.0,
|
||||
.unrealized_gain_loss = 433004.078,
|
||||
.unrealized_return = 0.875,
|
||||
}};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
|
||||
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$927,824") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "+$433,004") != null);
|
||||
// The ratio'd effective price fills the normally-blank Price cell.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$182.82") != null);
|
||||
// And the ratio-skipped figures appear nowhere.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "3,887,052") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "3,392,232") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$765.91") == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,31 @@ const Split = split.Split;
|
|||
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
|
||||
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
|
||||
// with a matching `is_preadjusted` flag per branch.
|
||||
//
|
||||
// ## Per-LOT display rows
|
||||
//
|
||||
// `valuation.Allocation` is a POSITION-level view, and its
|
||||
// `current_price` is the RAW base-ticker price - not any lot's
|
||||
// effective price. Worse, `valuation.mergeAllocsBySymbol` folds every
|
||||
// ratio variant of one ticker into a single row whose `shares` are
|
||||
// normalized into base-ticker-equivalent units. So the allocation is
|
||||
// self-consistent while telling you nothing directly usable about the
|
||||
// individual lots underneath it.
|
||||
//
|
||||
// A lot-detail row that multiplies its OWN raw shares by that raw price
|
||||
// is therefore wrong by exactly the lot's ratio. That shipped: a
|
||||
// direct-indexing sleeve with `price_ratio:num:0.2387` rendered a
|
||||
// +$3.39M gain against a real +$433K, and the lot rows under a position
|
||||
// summed to three times the position's own market value. Three sites had
|
||||
// independently hand-rolled the same broken expression.
|
||||
//
|
||||
// So: per-lot display sites MUST price through
|
||||
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`, which
|
||||
// resolves `close_price`, reads provenance off
|
||||
// `Allocation.is_manual_price`, and applies the ratio via
|
||||
// `Lot.effectivePrice`. Never read `Allocation.current_price` into a
|
||||
// per-lot calculation. Current callers: the CLI holdings table, the TUI
|
||||
// portfolio tab's lot rows, and the shared column-width pass.
|
||||
|
||||
// ── Share model (split adjustment) ──────────────────────────
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1922,18 +1922,24 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
// Compute lot gain/loss and market value if we have a price
|
||||
var lot_gl_str: []const u8 = "";
|
||||
var lot_mv_str: []const u8 = "";
|
||||
var lot_eff_price_str: []const u8 = "";
|
||||
var lot_positive = true;
|
||||
if (app.portfolio.summary) |s| {
|
||||
if (row.pos_idx < s.allocations.len) {
|
||||
const price = s.allocations[row.pos_idx].current_price;
|
||||
const use_price = lot.close_price orelse price;
|
||||
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
|
||||
lot_positive = gl >= 0;
|
||||
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
|
||||
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
|
||||
Money.from(if (gl >= 0) gl else -gl),
|
||||
});
|
||||
lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.effectiveShares() * use_price)});
|
||||
// The LOT's effective price, not its position's -
|
||||
// `Allocation.current_price` is the raw
|
||||
// base-ticker price, so a ratio'd lot must go
|
||||
// through `effectivePriceFor` or its Value and
|
||||
// Gain/Loss come out scaled by its ratio.
|
||||
const use_price = views.effectivePriceFor(s.allocations, lot);
|
||||
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
|
||||
lot_positive = gl >= 0;
|
||||
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
|
||||
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
|
||||
Money.from(if (gl >= 0) gl else -gl),
|
||||
});
|
||||
lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.effectiveShares() * use_price)});
|
||||
if (views.hasOwnPrice(lot)) {
|
||||
lot_eff_price_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(use_price)});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1956,7 +1962,10 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
var lot_cost_pad: [64]u8 = undefined;
|
||||
const lot_cost_cell = fmt.padLeftToCols(&lot_cost_pad, lot_price_str, cw.price_w);
|
||||
var lot_prc_pad: [64]u8 = undefined;
|
||||
const lot_prc_cell = fmt.padLeftToCols(&lot_prc_pad, "", cw.price_w); // blank current-price cell
|
||||
// Blank unless the lot is ratio'd, in which case it
|
||||
// prices off its own institutional NAV rather than
|
||||
// the base-ticker price on the position row above.
|
||||
const lot_prc_cell = fmt.padLeftToCols(&lot_prc_pad, lot_eff_price_str, cw.price_w);
|
||||
var lot_mv_pad: [64]u8 = undefined;
|
||||
const lot_mv_cell = fmt.padLeftToCols(&lot_mv_pad, lot_mv_str, cw.value_w);
|
||||
var lot_gl_pad: [64]u8 = undefined;
|
||||
|
|
|
|||
|
|
@ -117,7 +117,13 @@ pub fn computeWidths(
|
|||
if (lot.security_type != .stock) continue;
|
||||
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
|
||||
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
|
||||
const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol());
|
||||
const use_price = effectivePriceFor(allocations, lot);
|
||||
// A ratio'd lot renders its own effective price in the Price
|
||||
// cell (see `hasOwnPrice`), and that price can exceed every
|
||||
// allocation's raw price - an 8.6x institutional ratio puts a
|
||||
// $90 base ticker at $775. Observe it or the cell overflows its
|
||||
// column.
|
||||
if (hasOwnPrice(lot)) w.price_w = @max(w.price_w, moneyCols(use_price));
|
||||
w.value_w = @max(w.value_w, moneyCols(lot.effectiveShares() * use_price));
|
||||
w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.effectiveShares() * (use_price - lot.effectiveOpenPrice())));
|
||||
}
|
||||
|
|
@ -161,16 +167,71 @@ fn gainLossCols(amount: f64) usize {
|
|||
return 1 + moneyCols(if (amount < 0) -amount else amount);
|
||||
}
|
||||
|
||||
/// Current price for `symbol` from the allocations (linear scan;
|
||||
/// portfolios are small). Returns 0 when absent, which yields a
|
||||
/// trivially small cell that widens nothing.
|
||||
fn currentPriceFor(allocations: []const Allocation, symbol: []const u8) f64 {
|
||||
/// The effective price of a single LOT: the base-ticker price from
|
||||
/// `allocations` with this lot's `price_ratio` applied. The free-function
|
||||
/// counterpart to `Lot.effectivePrice`, which takes the raw price as an
|
||||
/// argument - this one resolves it from the allocations first. Returns 0
|
||||
/// when no allocation matches the lot's `priceSymbol()` (an orphan lot),
|
||||
/// which yields a trivially small cell that widens nothing and a
|
||||
/// zero-value row rather than a crash.
|
||||
///
|
||||
/// EVERY per-lot display site MUST price through this instead of
|
||||
/// reading `Allocation.current_price` directly.
|
||||
/// `Allocation.current_price` is the RAW base-ticker price (see its doc
|
||||
/// comment in `analytics/valuation.zig`), and `mergeAllocsBySymbol`
|
||||
/// normalizes a merged group's `shares` into base-ticker-equivalent
|
||||
/// units. A lot row that multiplies its own RAW shares by that RAW
|
||||
/// price is therefore off by exactly the lot's ratio. That shipped: a
|
||||
/// direct-indexing sleeve with `price_ratio:num:0.2387` rendered a
|
||||
/// +$3.39M gain against a real +$433K, and the lot rows under a
|
||||
/// position summed to three times the position's own market value.
|
||||
///
|
||||
/// Provenance follows the `is_preadjusted` rule from the pricing-model
|
||||
/// block in `models/portfolio.zig`:
|
||||
///
|
||||
/// - `close_price` is preadjusted (already the lot's own NAV), so a
|
||||
/// closed lot's ratio is NOT reapplied. Matches how the
|
||||
/// contributions pipeline values closed lots.
|
||||
/// - A manual `price::` is preadjusted too. `buildFallbackPrices`
|
||||
/// stores the raw override and flags the allocation
|
||||
/// `is_manual_price`, so that flag is the provenance signal here.
|
||||
/// - Anything else is a candle close: raw, so the ratio applies.
|
||||
///
|
||||
/// Known limit, deliberately not handled: a MERGED group that mixes
|
||||
/// manual-priced and live-priced components gets a blended
|
||||
/// `current_price` (`total_mv / norm_shares`) that is neither raw nor
|
||||
/// preadjusted, and a single `is_manual_price` bit cannot describe
|
||||
/// both. That is an expressiveness gap in `mergeAllocsBySymbol` itself,
|
||||
/// not something a lot-row accessor can repair. Merged groups whose
|
||||
/// components share one provenance - the common case, and the only case
|
||||
/// a ratio'd alias produces - are exact: with every component live off
|
||||
/// the same raw price `r`, `total_mv / norm_shares` reduces to `r`.
|
||||
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
|
||||
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
|
||||
for (allocations) |a| {
|
||||
if (std.mem.eql(u8, a.symbol, symbol)) return a.current_price;
|
||||
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
|
||||
return lot.effectivePrice(a.current_price, a.is_manual_price);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Whether a lot row should render its own effective price in the Price
|
||||
/// cell, which is otherwise blank because the position row above
|
||||
/// already shows it.
|
||||
///
|
||||
/// True only for a ratio'd lot, where the lot's price genuinely differs
|
||||
/// from its position's: the position row shows the base-ticker price
|
||||
/// ($90.15 SPYM) while the lot trades at its institutional NAV ($775.17
|
||||
/// at an 8.6x ratio). Showing it makes the ratio auditable at a glance
|
||||
/// - and a blank cell is exactly why a 7-figure ratio bug in the
|
||||
/// neighbouring Value and Gain/Loss cells went unnoticed.
|
||||
///
|
||||
/// Both the width pass and the two renderers gate on this, so they
|
||||
/// cannot disagree about whether the cell is occupied.
|
||||
pub fn hasOwnPrice(lot: Lot) bool {
|
||||
return lot.price_ratio != 1.0;
|
||||
}
|
||||
|
||||
/// Write `content` into a `width`-column field: right-justified (spaces
|
||||
/// before) when `right`, else left-justified (spaces after).
|
||||
/// Display-column aware. The streaming analogue of
|
||||
|
|
@ -523,6 +584,12 @@ pub const CDs = struct {
|
|||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
// Test-only, and the module rather than a type extraction: the
|
||||
// lot-row/position-row reconciliation invariant has to go through the
|
||||
// real `positionsAsOf` -> `portfolioSummary` -> `mergeAllocsBySymbol`
|
||||
// pipeline, because the bug lived in the seam between them.
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
|
||||
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
|
|
@ -592,11 +659,192 @@ test "CDs.init: all matured yields empty active slice" {
|
|||
try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
|
||||
}
|
||||
|
||||
// ── effectivePriceFor / hasOwnPrice ─────────────────────────────
|
||||
|
||||
test "effectivePriceFor: live price gets the lot's ratio applied" {
|
||||
// The bug this guards: `Allocation.current_price` is the RAW
|
||||
// base-ticker price. A proxied sleeve quoted off a $90.15 base at an
|
||||
// 8.6x institutional ratio prices at $775.17, not $90.15.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 6097.4, 53.65, 90.15, 549779, 222651)};
|
||||
const lot = Lot{
|
||||
.symbol = "DI-IDX",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 8.598685594945021,
|
||||
.shares = 709.235272,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
};
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
||||
// The sign of the failure matters: an under-1.0 ratio is what
|
||||
// produced the +$3.39M phantom gain, because skipping the ratio
|
||||
// INFLATES the price. 5075.077 shares at a raw $765.91 is $3.89M;
|
||||
// the real effective price is 765.91 * 0.2387 = $182.82 -> $927,824.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 1211.4, 408.47, 765.91, 927824, 433004)};
|
||||
const lot = Lot{
|
||||
.symbol = "AGG-LC",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 0.2386960690140873,
|
||||
.shares = 5075.077,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 97.50,
|
||||
};
|
||||
const eff_price = effectivePriceFor(&allocs, lot);
|
||||
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
|
||||
// The whole point: value must land on the real figure, not the
|
||||
// ratio-skipped one.
|
||||
try testing.expectApproxEqRel(@as(f64, 927824.086), lot.effectiveShares() * eff_price, 1e-6);
|
||||
try testing.expect(lot.effectiveShares() * eff_price < 1_000_000);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: manual price is preadjusted, so the ratio is NOT reapplied" {
|
||||
// `buildFallbackPrices` stores the raw `price::` override and flags
|
||||
// the allocation `is_manual_price`. A manual price is already the
|
||||
// lot's own NAV by convention, so multiplying by the ratio would
|
||||
// double-apply it.
|
||||
var alloc = mkAlloc("ORCX", 100, 18.15, 19.01, 1901, 86);
|
||||
alloc.is_manual_price = true;
|
||||
const allocs = [_]Allocation{alloc};
|
||||
const lot = Lot{
|
||||
.symbol = "ORCX",
|
||||
.price_ratio = 5.0,
|
||||
.shares = 100,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 18.15,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
|
||||
// A closed lot's `close_price` is the price it actually closed at -
|
||||
// already in the lot's own units. Matches how the contributions
|
||||
// pipeline values closed lots (`effectivePrice(close_price, true)`).
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
const lot = Lot{
|
||||
.symbol = "CUSIP1",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 5.061982036579556,
|
||||
.shares = 2412.601,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 106.99,
|
||||
.close_price = 150.39,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
|
||||
// A CUSIP lot must find its allocation under the ticker alias.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
|
||||
var aliased = by_cusip;
|
||||
aliased.ticker = "BENCH";
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
|
||||
}
|
||||
|
||||
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
|
||||
const plain = Lot{ .symbol = "ABC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1 };
|
||||
try testing.expect(!hasOwnPrice(plain));
|
||||
var ratioed = plain;
|
||||
ratioed.price_ratio = 8.6;
|
||||
try testing.expect(hasOwnPrice(ratioed));
|
||||
// Under 1.0 counts too - that direction is the one that inflated.
|
||||
ratioed.price_ratio = 0.2387;
|
||||
try testing.expect(hasOwnPrice(ratioed));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: lot rows sum to their merged position's market value" {
|
||||
// The exact shape that shipped the bug. Two proxied sleeves alias
|
||||
// one base ticker with DIFFERENT ratios, so `positionsAsOf` groups
|
||||
// them into two Positions and `mergeAllocsBySymbol` collapses those
|
||||
// into ONE Allocation whose `shares` are normalized into
|
||||
// base-ticker-equivalent units and whose `current_price` is the RAW
|
||||
// base-ticker price.
|
||||
//
|
||||
// A lot row that multiplied its own raw shares by that raw price
|
||||
// rendered 5075.077 * $765.91 = $3.89M for a lot really worth
|
||||
// $928K, and the two lot rows summed to three times the position
|
||||
// row above them. This asserts they reconcile.
|
||||
const base_price: f64 = 765.91;
|
||||
var lots = [_]Lot{
|
||||
.{
|
||||
.symbol = "DI-IDX",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 1.0120921601549708,
|
||||
.shares = 709.235272,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
.account = "Sample Trust",
|
||||
},
|
||||
.{
|
||||
.symbol = "AGG-LC",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 0.2386960690140873,
|
||||
.shares = 5075.077,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 97.50,
|
||||
.account = "Sample 401(k)",
|
||||
},
|
||||
};
|
||||
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = testing.allocator };
|
||||
const as_of = Date.fromYmd(2026, 8, 26);
|
||||
|
||||
const positions = try pf.positionsAsOf(testing.allocator, as_of);
|
||||
defer testing.allocator.free(positions);
|
||||
// Same ticker, different ratios -> two Positions, not one.
|
||||
try testing.expectEqual(@as(usize, 2), positions.len);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("BENCH", base_price);
|
||||
|
||||
var summary = try valuation.portfolioSummary(as_of, testing.allocator, pf, positions, prices, null);
|
||||
defer summary.deinit(testing.allocator);
|
||||
|
||||
// ...which merge back into one Allocation.
|
||||
try testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
||||
const a = summary.allocations[0];
|
||||
try testing.expectEqualStrings("BENCH", a.symbol);
|
||||
// Merged rows normalize to base units, so the ratio is spent.
|
||||
try testing.expectEqual(@as(f64, 1.0), a.price_ratio);
|
||||
try testing.expectApproxEqRel(base_price, a.current_price, 1e-9);
|
||||
|
||||
var lot_mv_total: f64 = 0;
|
||||
var lot_gl_total: f64 = 0;
|
||||
for (lots) |lot| {
|
||||
const eff_price = effectivePriceFor(summary.allocations, lot);
|
||||
lot_mv_total += lot.effectiveShares() * eff_price;
|
||||
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
|
||||
}
|
||||
try testing.expectApproxEqRel(a.market_value, lot_mv_total, 1e-9);
|
||||
try testing.expectApproxEqRel(a.unrealized_gain_loss, lot_gl_total, 1e-9);
|
||||
|
||||
// And pin the magnitude, so a future regression that merely
|
||||
// reconciles two equally-wrong numbers still fails.
|
||||
try testing.expectApproxEqRel(@as(f64, 1_477_603.06), lot_mv_total, 1e-6);
|
||||
try testing.expectApproxEqRel(@as(f64, 655_655.23), lot_gl_total, 1e-6);
|
||||
}
|
||||
|
||||
// ── computeWidths ─────────────────────────────────────────────
|
||||
|
||||
/// Build a minimal Allocation for width tests. `cost_basis` is derived
|
||||
/// so `unrealized_gain_loss` is consistent (not that computeWidths
|
||||
/// reads cost_basis, but it keeps the fixture honest).
|
||||
/// Build a minimal Allocation for the width and effective-price tests.
|
||||
/// `cost_basis` is derived so `unrealized_gain_loss` is consistent (not
|
||||
/// that computeWidths reads cost_basis, but it keeps the fixture
|
||||
/// honest).
|
||||
fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation {
|
||||
return .{
|
||||
.symbol = symbol,
|
||||
|
|
@ -612,6 +860,50 @@ fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, m
|
|||
};
|
||||
}
|
||||
|
||||
test "computeWidths: a ratio'd lot's effective price widens the Price column" {
|
||||
// A ratio'd lot renders its own effective price in the
|
||||
// otherwise-blank Price cell, and at an 8.6x institutional ratio
|
||||
// that price dwarfs every allocation figure: base ticker $90.15 (6
|
||||
// cols), avg cost $53.65 (6), lot open price $461.24 (7) - but the
|
||||
// effective price is $775.17 (7)... so push the ratio higher to make
|
||||
// it the strict maximum and prove the width pass observes it.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 53.65, 90.15, 9015, 1000)};
|
||||
const lots = [_]Lot{.{
|
||||
.symbol = "DI-IDX",
|
||||
.ticker = "BENCH",
|
||||
.price_ratio = 150.0, // effective = $13,522.50 -> "$13,522.50" = 10 cols
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 100.0,
|
||||
}};
|
||||
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w);
|
||||
}
|
||||
|
||||
test "computeWidths: the Price cell is measured only when the lot occupies it" {
|
||||
// Needs an effective price that is NOT already measured from the
|
||||
// allocations, or the gate is unfalsifiable: for an open ratio-1.0
|
||||
// lot the effective price IS `alloc.current_price`, which is always
|
||||
// measured. A closed lot's comes from its own `close_price` instead,
|
||||
// so it is visible to the lot pass alone.
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
var lots = [_]Lot{.{
|
||||
.symbol = "ABC",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 1.0,
|
||||
.close_price = 99999.99, // "$99,999.99" = 10 cols
|
||||
}};
|
||||
|
||||
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
|
||||
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
|
||||
// Ratio'd: the cell is occupied, so the same price now widens it.
|
||||
lots[0].price_ratio = 2.0;
|
||||
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
}
|
||||
|
||||
test "computeWidths: empty portfolio sits at the header-label floors" {
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue