Compare commits

..

No commits in common. "2ac4156bc1227ca7691a22a35411ff77064ae697" and "ecadfb492d8730c3226c738b6cb63e688d7f89dc" have entirely different histories.

3 changed files with 48 additions and 60 deletions

View file

@ -146,28 +146,20 @@ pub const TrailingReturns = struct {
ten_year: ?PerformanceResult = null, ten_year: ?PerformanceResult = null,
}; };
/// Merge adj_close and dividend-reinvestment returns, preferring the higher /// Fill gaps in `primary` with results from `fallback`.
/// annualized return for each period. This works because: /// Used when adj_close returns (which account for splits + dividends) are the
/// - When dividend data is complete: reinvestment >= adj_close (compounding) /// primary source, but may return null for some periods (e.g. candle history
/// - When dividend data is incomplete: adj_close > reinvestment (missing dividends) /// too short). The fallback typically dividend-reinvestment with stable-NAV
/// So the higher value is always the more correct one. /// synthesis can cover those gaps.
pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: TrailingReturns) TrailingReturns { pub fn withFallback(primary: TrailingReturns, fallback: TrailingReturns) TrailingReturns {
return .{ return .{
.one_year = bestResult(div_returns.one_year, adj_close_returns.one_year), .one_year = primary.one_year orelse fallback.one_year,
.three_year = bestResult(div_returns.three_year, adj_close_returns.three_year), .three_year = primary.three_year orelse fallback.three_year,
.five_year = bestResult(div_returns.five_year, adj_close_returns.five_year), .five_year = primary.five_year orelse fallback.five_year,
.ten_year = bestResult(div_returns.ten_year, adj_close_returns.ten_year), .ten_year = primary.ten_year orelse fallback.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 {
@ -591,41 +583,39 @@ test "stable-NAV synthesis -- non-$1 fund does not synthesize" {
try std.testing.expect(result == null); try std.testing.expect(result == null);
} }
test "withDividendFallback -- picks higher annualized return per period" { test "withFallback -- fills null periods from fallback" {
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);
// div_returns: complete dividend data, higher returns from compounding const primary: TrailingReturns = .{
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, // dividend data too short for 5yr .five_year = null,
.ten_year = null, // dividend data too short for 10yr .ten_year = null,
}; };
// adj_close_returns: always available but slightly lower due to non-compounding const fallback: TrailingReturns = .{
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 = withDividendFallback(div_ret, adj_ret); const merged = withFallback(primary, fallback);
// one_year: div wins (0.10 > 0.08, complete dividend data compounds better) // 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); try std.testing.expectApproxEqAbs(@as(f64, 0.10), merged.one_year.?.annualized_return.?, 0.001);
// three_year: adj_close wins (0.17 > 0.15, incomplete dividend data here) // three_year: primary has data, keeps it
try std.testing.expectApproxEqAbs(@as(f64, 0.17), merged.three_year.?.annualized_return.?, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.15), merged.three_year.?.annualized_return.?, 0.001);
// five_year: div null, filled from adj_close // five_year: primary null, filled from fallback
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: div null, filled from adj_close // ten_year: primary null, filled from fallback
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 "withDividendFallback -- both null stays null" { test "withFallback -- both null stays null" {
const a: TrailingReturns = .{}; const a: TrailingReturns = .{};
const b: TrailingReturns = .{}; const b: TrailingReturns = .{};
const merged = withDividendFallback(a, b); const merged = withFallback(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);
} }
@ -662,16 +652,15 @@ 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 withDividendFallback: // Wrapping in TrailingReturns to test withFallback:
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 };
// withDividendFallback picks the higher return for each period, // withFallback(div, adj) keeps div_result where available
// so dividend reinvestment wins regardless of argument order const total = withFallback(div_tr, adj_tr);
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);
// Same result with reversed order bestResult always picks higher // Reversed order (adj_close primary) would give ~0% wrong for splits-only
const also_total = withDividendFallback(adj_tr, div_tr); const wrong = withFallback(adj_tr, div_tr);
try std.testing.expect(also_total.one_year.?.total_return > 0.05); try std.testing.expectApproxEqAbs(@as(f64, 0.0), wrong.one_year.?.total_return, 0.01);
} }

View file

@ -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);
// Dividend reinvestment is preferred (compounds correctly). me_total = performance.withFallback(me_price, me_div);
// 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 .{

View file

@ -22,20 +22,18 @@ pub fn loadData(app: *App) void {
app.candle_first_date = null; app.candle_first_date = null;
app.candle_last_date = null; app.candle_last_date = null;
const result = app.svc.getTrailingReturns(app.symbol) catch |err| { const candle_result = app.svc.getCandles(app.symbol) catch |err| {
switch (err) { switch (err) {
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"), zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"),
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
zfin.DataError.TransientError => app.setStatus("Provider temporarily unavailable — try again later"),
zfin.DataError.AuthError => app.setStatus("API key auth failed — check TIINGO_API_KEY"),
else => app.setStatus("Error loading data"), else => app.setStatus("Error loading data"),
} }
return; return;
}; };
app.candles = result.candles; app.candles = candle_result.data;
app.candle_timestamp = result.timestamp; app.candle_timestamp = candle_result.timestamp;
const c = result.candles; const c = app.candles.?;
if (c.len == 0) { if (c.len == 0) {
app.setStatus("No data available for symbol"); app.setStatus("No data available for symbol");
return; return;
@ -44,14 +42,15 @@ pub fn loadData(app: *App) void {
app.candle_first_date = c[0].date; app.candle_first_date = c[0].date;
app.candle_last_date = c[c.len - 1].date; app.candle_last_date = c[c.len - 1].date;
app.trailing_price = result.asof_price; const today = fmt.todayDate();
app.trailing_me_price = result.me_price; app.trailing_price = zfin.performance.trailingReturns(c);
app.trailing_total = result.asof_total; app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
app.trailing_me_total = result.me_total;
if (result.dividends) |divs| { if (app.svc.getDividends(app.symbol)) |div_result| {
app.dividends = divs; app.dividends = div_result.data;
} app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {}
app.risk_metrics = zfin.risk.trailingRisk(c); app.risk_metrics = zfin.risk.trailingRisk(c);
@ -65,7 +64,7 @@ pub fn loadData(app: *App) void {
} else |_| {} } else |_| {}
} }
app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
} }
// Rendering // Rendering