synthesize money market data if candles do not cover dividend data
All checks were successful
Generic zig build / build (push) Successful in 30s

This commit is contained in:
Emil Lerch 2026-03-21 13:24:14 -07:00
parent 7c392cec43
commit 913996072e
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -89,7 +89,13 @@ fn totalReturnWithDividendsSnap(
to: Date,
start_dir: SearchDirection,
) ?PerformanceResult {
const start = findNearestCandle(candles, from, start_dir) orelse return null;
const start = findNearestCandle(candles, from, start_dir) orelse
// Stable-NAV fund (e.g. money market): synthesize start candle at $1
// when candle history doesn't reach back far enough.
if (candles.len > 0 and from.lessThan(candles[0].date) and candles[0].close == 1.0)
stableNavCandle(from)
else
return null;
const end = findNearestCandle(candles, to, .backward) orelse return null;
if (start.close == 0) return null;
@ -101,8 +107,13 @@ fn totalReturnWithDividendsSnap(
if (div.ex_date.lessThan(start.date)) continue;
if (end.date.lessThan(div.ex_date)) break;
// Find close price on or near the ex-date
const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse continue;
// Find close price on or near the ex-date.
// For stable-NAV funds, dividends before candle history use $1.
const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse
if (start.close == 1.0)
stableNavCandle(div.ex_date)
else
continue;
if (price_candle.close > 0) {
shares += (div.amount * shares) / price_candle.close;
}
@ -120,6 +131,12 @@ fn totalReturnWithDividendsSnap(
};
}
/// Synthesize a candle with stable $1 NAV for a given date.
/// Used for money market funds whose NAV is always $1.
fn stableNavCandle(date: Date) Candle {
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
}
/// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close.
/// Uses the last available date as the endpoint.
pub const TrailingReturns = struct {
@ -505,3 +522,48 @@ test "as-of-date vs month-end -- different results from same data" {
try std.testing.expect(me.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001);
}
test "stable-NAV fund -- synthesize start candle for dividend reinvestment" {
// Money market fund: NAV always $1, candle history only covers 3 years,
// but dividend data goes back 5 years. Should synthesize a $1 start candle
// and correctly compound distributions for the full 5-year period.
//
// Monthly $0.003 distribution on $1 NAV, 60 months:
// shares = (1.003)^60 = 1.19668...
// total return = 19.67%
const candles = [_]Candle{
makeCandle(Date.fromYmd(2023, 1, 3), 1), // candles start here (3yr)
makeCandle(Date.fromYmd(2024, 1, 2), 1),
makeCandle(Date.fromYmd(2025, 12, 31), 1),
};
// 60 monthly dividends from 2021 through 2025
var divs: [60]Dividend = undefined;
for (0..60) |i| {
const month: u8 = @intCast(i % 12 + 1);
const year: i16 = @intCast(2021 + i / 12);
divs[i] = .{ .ex_date = Date.fromYmd(year, month, 15), .amount = 0.003 };
}
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2021, 1, 1), Date.fromYmd(2026, 1, 1));
try std.testing.expect(result != null);
// Start date should be synthesized at the requested from date (snapped forward)
try std.testing.expect(result.?.from.eql(Date.fromYmd(2021, 1, 1)));
// (1.003)^60 - 1 = 0.19668
const expected = std.math.pow(f64, 1.003, 60.0) - 1.0;
try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.001);
}
test "stable-NAV synthesis -- non-$1 fund does not synthesize" {
// A fund with close != $1 should NOT get a synthesized start candle.
const candles = [_]Candle{
makeCandle(Date.fromYmd(2023, 1, 3), 50),
makeCandle(Date.fromYmd(2025, 12, 31), 55),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2021, 6, 15), .amount = 1.0 },
};
// Start date is before candle history, but close != $1 => should return null
const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2021, 1, 1), Date.fromYmd(2026, 1, 1));
try std.testing.expect(result == null);
}