diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index a859c05..619c96e 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -146,6 +146,20 @@ pub const TrailingReturns = struct { ten_year: ?PerformanceResult = null, }; +/// Fill gaps in `primary` with results from `fallback`. +/// Used when adj_close returns (which account for splits + dividends) are the +/// primary source, but may return null for some periods (e.g. candle history +/// too short). The fallback — typically dividend-reinvestment with stable-NAV +/// synthesis — can cover those gaps. +pub fn withFallback(primary: TrailingReturns, fallback: TrailingReturns) TrailingReturns { + return .{ + .one_year = primary.one_year orelse fallback.one_year, + .three_year = primary.three_year orelse fallback.three_year, + .five_year = primary.five_year orelse fallback.five_year, + .ten_year = primary.ten_year orelse fallback.ten_year, + }; +} + /// Trailing returns from exact calendar date N years ago to latest candle date. /// Start dates snap forward to the next trading day (e.g., weekend → Monday). pub fn trailingReturns(candles: []const Candle) TrailingReturns { @@ -567,3 +581,40 @@ test "stable-NAV synthesis -- non-$1 fund does not synthesize" { const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2021, 1, 1), Date.fromYmd(2026, 1, 1)); try std.testing.expect(result == null); } + +test "withFallback -- fills null periods from fallback" { + const d1 = Date.fromYmd(2020, 1, 1); + const d2 = Date.fromYmd(2025, 1, 1); + + const primary: TrailingReturns = .{ + .one_year = .{ .total_return = 0.10, .annualized_return = 0.10, .from = d1, .to = d2 }, + .three_year = .{ .total_return = 0.50, .annualized_return = 0.15, .from = d1, .to = d2 }, + .five_year = null, + .ten_year = null, + }; + const fallback: TrailingReturns = .{ + .one_year = .{ .total_return = 0.08, .annualized_return = 0.08, .from = d1, .to = d2 }, + .three_year = .{ .total_return = 0.60, .annualized_return = 0.17, .from = d1, .to = d2 }, + .five_year = .{ .total_return = 0.80, .annualized_return = 0.12, .from = d1, .to = d2 }, + .ten_year = .{ .total_return = 0.50, .annualized_return = 0.04, .from = d1, .to = d2 }, + }; + + const merged = withFallback(primary, fallback); + + // one_year: primary has data, keeps it (not overwritten by fallback) + try std.testing.expectApproxEqAbs(@as(f64, 0.10), merged.one_year.?.annualized_return.?, 0.001); + // three_year: primary has data, keeps it + try std.testing.expectApproxEqAbs(@as(f64, 0.15), merged.three_year.?.annualized_return.?, 0.001); + // five_year: primary null, filled from fallback + try std.testing.expectApproxEqAbs(@as(f64, 0.12), merged.five_year.?.annualized_return.?, 0.001); + // ten_year: primary null, filled from fallback + try std.testing.expectApproxEqAbs(@as(f64, 0.04), merged.ten_year.?.annualized_return.?, 0.001); +} + +test "withFallback -- both null stays null" { + const a: TrailingReturns = .{}; + const b: TrailingReturns = .{}; + const merged = withFallback(a, b); + try std.testing.expect(merged.one_year == null); + try std.testing.expect(merged.ten_year == null); +} diff --git a/src/service.zig b/src/service.zig index b15c695..31600ac 100644 --- a/src/service.zig +++ b/src/service.zig @@ -596,8 +596,14 @@ pub const DataService = struct { if (self.getDividends(symbol)) |div_result| { divs = div_result.data; - asof_total = performance.trailingReturnsWithDividends(c, div_result.data); - me_total = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); + // adj_close returns are the primary total return source (accounts for + // splits + dividends). Dividend-reinvestment is only used as a fallback + // for periods where adj_close returns null (e.g. candle history too short + // for stable-NAV funds like money markets). + const asof_div = performance.trailingReturnsWithDividends(c, div_result.data); + asof_total = performance.withFallback(asof_price, asof_div); + const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); + me_total = performance.withFallback(me_price, me_div); } else |_| {} return .{