From 913996072ee06c81121f50657e325ce8a56856e6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 21 Mar 2026 13:24:14 -0700 Subject: [PATCH] synthesize money market data if candles do not cover dividend data --- src/analytics/performance.zig | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index f893e59..a859c05 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -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); +}