diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index bdd3c59..574ef30 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -146,20 +146,28 @@ 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 { +/// Merge adj_close and dividend-reinvestment returns, preferring the higher +/// annualized return for each period. This works because: +/// - When dividend data is complete: reinvestment >= adj_close (compounding) +/// - When dividend data is incomplete: adj_close > reinvestment (missing dividends) +/// So the higher value is always the more correct one. +pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: 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, + .one_year = bestResult(div_returns.one_year, adj_close_returns.one_year), + .three_year = bestResult(div_returns.three_year, adj_close_returns.three_year), + .five_year = bestResult(div_returns.five_year, adj_close_returns.five_year), + .ten_year = bestResult(div_returns.ten_year, adj_close_returns.ten_year), }; } +fn bestResult(a: ?PerformanceResult, b: ?PerformanceResult) ?PerformanceResult { + const aa = a orelse return b; + const bb = b orelse return a; + const a_ann = aa.annualized_return orelse return b; + const b_ann = bb.annualized_return orelse return a; + return if (a_ann >= b_ann) a else b; +} + /// 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 { @@ -583,39 +591,41 @@ test "stable-NAV synthesis -- non-$1 fund does not synthesize" { try std.testing.expect(result == null); } -test "withFallback -- fills null periods from fallback" { +test "withDividendFallback -- picks higher annualized return per period" { const d1 = Date.fromYmd(2020, 1, 1); const d2 = Date.fromYmd(2025, 1, 1); - const primary: TrailingReturns = .{ + // div_returns: complete dividend data, higher returns from compounding + const div_ret: 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, + .five_year = null, // dividend data too short for 5yr + .ten_year = null, // dividend data too short for 10yr }; - const fallback: TrailingReturns = .{ + // adj_close_returns: always available but slightly lower due to non-compounding + const adj_ret: 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); + const merged = withDividendFallback(div_ret, adj_ret); - // one_year: primary has data, keeps it (not overwritten by fallback) + // one_year: div wins (0.10 > 0.08, complete dividend data compounds better) 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 + // three_year: adj_close wins (0.17 > 0.15, incomplete dividend data here) + try std.testing.expectApproxEqAbs(@as(f64, 0.17), merged.three_year.?.annualized_return.?, 0.001); + // five_year: div null, filled from adj_close try std.testing.expectApproxEqAbs(@as(f64, 0.12), merged.five_year.?.annualized_return.?, 0.001); - // ten_year: primary null, filled from fallback + // ten_year: div null, filled from adj_close try std.testing.expectApproxEqAbs(@as(f64, 0.04), merged.ten_year.?.annualized_return.?, 0.001); } -test "withFallback -- both null stays null" { +test "withDividendFallback -- both null stays null" { const a: TrailingReturns = .{}; const b: TrailingReturns = .{}; - const merged = withFallback(a, b); + const merged = withDividendFallback(a, b); try std.testing.expect(merged.one_year == null); try std.testing.expect(merged.ten_year == null); } @@ -652,15 +662,16 @@ test "splits-only adj_close -- dividend reinvestment preferred" { try std.testing.expect(div_result.?.total_return > 0.05); // When adj_close is splits-only, dividend reinvestment should be primary. - // Wrapping in TrailingReturns to test withFallback: + // Wrapping in TrailingReturns to test withDividendFallback: const adj_tr: TrailingReturns = .{ .one_year = adj_result }; const div_tr: TrailingReturns = .{ .one_year = div_result }; - // withFallback(div, adj) keeps div_result where available - const total = withFallback(div_tr, adj_tr); + // withDividendFallback picks the higher return for each period, + // so dividend reinvestment wins regardless of argument order + const total = withDividendFallback(div_tr, adj_tr); try std.testing.expect(total.one_year.?.total_return > 0.05); - // Reversed order (adj_close primary) would give ~0% — wrong for splits-only - const wrong = withFallback(adj_tr, div_tr); - try std.testing.expectApproxEqAbs(@as(f64, 0.0), wrong.one_year.?.total_return, 0.01); + // Same result with reversed order — bestResult always picks higher + const also_total = withDividendFallback(adj_tr, div_tr); + try std.testing.expect(also_total.one_year.?.total_return > 0.05); } diff --git a/src/service.zig b/src/service.zig index 8b5061f..7489705 100644 --- a/src/service.zig +++ b/src/service.zig @@ -644,13 +644,13 @@ pub const DataService = struct { if (self.getDividends(symbol)) |div_result| { divs = div_result.data; - // adj_close is the primary total return source (accounts for splits + - // dividends). Dividend-reinvestment only fills gaps where adj_close - // returns null (e.g. stable-NAV funds with short candle history). 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); + // Dividend reinvestment is preferred (compounds correctly). + // adj_close fills gaps where dividend data is insufficient + // (e.g. stable-NAV funds with short candle history). + asof_total = performance.withDividendFallback(asof_div, asof_price); + me_total = performance.withDividendFallback(me_div, me_price); } else |_| {} return .{