merge adj_close and div reinvestment strategies to compensate for data inconsistency
All checks were successful
Generic zig build / build (push) Successful in 29s
All checks were successful
Generic zig build / build (push) Successful in 29s
This commit is contained in:
parent
ac5e0639fc
commit
2ac4156bc1
2 changed files with 45 additions and 34 deletions
|
|
@ -146,20 +146,28 @@ pub const TrailingReturns = struct {
|
||||||
ten_year: ?PerformanceResult = null,
|
ten_year: ?PerformanceResult = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Fill gaps in `primary` with results from `fallback`.
|
/// Merge adj_close and dividend-reinvestment returns, preferring the higher
|
||||||
/// Used when adj_close returns (which account for splits + dividends) are the
|
/// annualized return for each period. This works because:
|
||||||
/// primary source, but may return null for some periods (e.g. candle history
|
/// - When dividend data is complete: reinvestment >= adj_close (compounding)
|
||||||
/// too short). The fallback — typically dividend-reinvestment with stable-NAV
|
/// - When dividend data is incomplete: adj_close > reinvestment (missing dividends)
|
||||||
/// synthesis — can cover those gaps.
|
/// So the higher value is always the more correct one.
|
||||||
pub fn withFallback(primary: TrailingReturns, fallback: TrailingReturns) TrailingReturns {
|
pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: TrailingReturns) TrailingReturns {
|
||||||
return .{
|
return .{
|
||||||
.one_year = primary.one_year orelse fallback.one_year,
|
.one_year = bestResult(div_returns.one_year, adj_close_returns.one_year),
|
||||||
.three_year = primary.three_year orelse fallback.three_year,
|
.three_year = bestResult(div_returns.three_year, adj_close_returns.three_year),
|
||||||
.five_year = primary.five_year orelse fallback.five_year,
|
.five_year = bestResult(div_returns.five_year, adj_close_returns.five_year),
|
||||||
.ten_year = primary.ten_year orelse fallback.ten_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.
|
/// 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).
|
/// Start dates snap forward to the next trading day (e.g., weekend → Monday).
|
||||||
pub fn trailingReturns(candles: []const Candle) TrailingReturns {
|
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);
|
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 d1 = Date.fromYmd(2020, 1, 1);
|
||||||
const d2 = Date.fromYmd(2025, 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 },
|
.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 },
|
.three_year = .{ .total_return = 0.50, .annualized_return = 0.15, .from = d1, .to = d2 },
|
||||||
.five_year = null,
|
.five_year = null, // dividend data too short for 5yr
|
||||||
.ten_year = null,
|
.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 },
|
.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 },
|
.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 },
|
.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 },
|
.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);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.10), merged.one_year.?.annualized_return.?, 0.001);
|
||||||
// three_year: primary has data, keeps it
|
// three_year: adj_close wins (0.17 > 0.15, incomplete dividend data here)
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.15), merged.three_year.?.annualized_return.?, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.17), merged.three_year.?.annualized_return.?, 0.001);
|
||||||
// five_year: primary null, filled from fallback
|
// five_year: div null, filled from adj_close
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.12), merged.five_year.?.annualized_return.?, 0.001);
|
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);
|
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 a: TrailingReturns = .{};
|
||||||
const b: 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.one_year == null);
|
||||||
try std.testing.expect(merged.ten_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);
|
try std.testing.expect(div_result.?.total_return > 0.05);
|
||||||
|
|
||||||
// When adj_close is splits-only, dividend reinvestment should be primary.
|
// 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 adj_tr: TrailingReturns = .{ .one_year = adj_result };
|
||||||
const div_tr: TrailingReturns = .{ .one_year = div_result };
|
const div_tr: TrailingReturns = .{ .one_year = div_result };
|
||||||
|
|
||||||
// withFallback(div, adj) keeps div_result where available
|
// withDividendFallback picks the higher return for each period,
|
||||||
const total = withFallback(div_tr, adj_tr);
|
// 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);
|
try std.testing.expect(total.one_year.?.total_return > 0.05);
|
||||||
|
|
||||||
// Reversed order (adj_close primary) would give ~0% — wrong for splits-only
|
// Same result with reversed order — bestResult always picks higher
|
||||||
const wrong = withFallback(adj_tr, div_tr);
|
const also_total = withDividendFallback(adj_tr, div_tr);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), wrong.one_year.?.total_return, 0.01);
|
try std.testing.expect(also_total.one_year.?.total_return > 0.05);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -644,13 +644,13 @@ pub const DataService = struct {
|
||||||
|
|
||||||
if (self.getDividends(symbol)) |div_result| {
|
if (self.getDividends(symbol)) |div_result| {
|
||||||
divs = div_result.data;
|
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);
|
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);
|
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 |_| {}
|
} else |_| {}
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue