Compare commits

...

2 commits

Author SHA1 Message Date
ecadfb492d
more measured fallback and logging
All checks were successful
Generic zig build / build (push) Successful in 31s
2026-03-24 18:57:22 -07:00
ee9d749a6f
disable twelvedata candles due to inconsistent dividend handling 2026-03-24 18:17:19 -07:00
3 changed files with 203 additions and 111 deletions

View file

@ -62,14 +62,14 @@ fn totalReturnFromAdjCloseSnap(candles: []const Candle, from: Date, to: Date, st
/// Compute total return with manual dividend reinvestment. /// Compute total return with manual dividend reinvestment.
/// Uses raw close prices and dividend records independently. /// Uses raw close prices and dividend records independently.
/// Candles and dividends must be sorted by date ascending. /// Candles and dividends must be sorted by date ascending.
/// `from` snaps forward, `to` snaps backward. /// `from` snaps backward (last trading day on/before), `to` snaps backward.
pub fn totalReturnWithDividends( pub fn totalReturnWithDividends(
candles: []const Candle, candles: []const Candle,
dividends: []const Dividend, dividends: []const Dividend,
from: Date, from: Date,
to: Date, to: Date,
) ?PerformanceResult { ) ?PerformanceResult {
return totalReturnWithDividendsSnap(candles, dividends, from, to, .forward); return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward);
} }
/// Same as totalReturnWithDividends but both dates snap backward. /// Same as totalReturnWithDividends but both dates snap backward.
@ -105,7 +105,7 @@ fn totalReturnWithDividendsSnap(
for (dividends) |div| { for (dividends) |div| {
if (div.ex_date.lessThan(start.date)) continue; if (div.ex_date.lessThan(start.date)) continue;
if (end.date.lessThan(div.ex_date)) break; if (end.date.lessThan(div.ex_date)) continue;
// Find close price on or near the ex-date. // Find close price on or near the ex-date.
// For stable-NAV funds, dividends before candle history use $1. // For stable-NAV funds, dividends before candle history use $1.
@ -619,3 +619,48 @@ test "withFallback -- both null stays null" {
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);
} }
test "splits-only adj_close -- dividend reinvestment preferred" {
// Simulates a TwelveData-like provider where adj_close only accounts for splits.
// Stock pays $2/quarter dividend, price stays at ~$100.
// adj_close shows no return (splits-only), but dividend reinvestment should
// show the correct total return from the distributions.
const candles = [_]Candle{
// adj_close == close here (no splits), so adj_close return 0%
makeCandle(Date.fromYmd(2025, 1, 2), 100),
makeCandle(Date.fromYmd(2025, 3, 15), 100),
makeCandle(Date.fromYmd(2025, 6, 15), 100),
makeCandle(Date.fromYmd(2025, 9, 15), 100),
makeCandle(Date.fromYmd(2025, 12, 31), 100),
};
const divs = [_]Dividend{
.{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 2.0 },
.{ .ex_date = Date.fromYmd(2025, 6, 15), .amount = 2.0 },
.{ .ex_date = Date.fromYmd(2025, 9, 15), .amount = 2.0 },
.{ .ex_date = Date.fromYmd(2025, 12, 15), .amount = 2.0 },
};
// adj_close return should be ~0% (price flat, no dividend adjustment)
const adj_result = totalReturnFromAdjClose(&candles, Date.fromYmd(2025, 1, 2), Date.fromYmd(2025, 12, 31));
try std.testing.expect(adj_result != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), adj_result.?.total_return, 0.01);
// Dividend reinvestment: 3 of 4 dividends are reinvested (Dec 15 is >10 days
// from any candle so it's skipped by snap tolerance). ~6.12% total return.
const div_result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2025, 1, 2), Date.fromYmd(2025, 12, 31));
try std.testing.expect(div_result != null);
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:
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);
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);
}

24
src/cache/store.zig vendored
View file

@ -215,7 +215,7 @@ pub const Store = struct {
/// Write a full set of candles to cache (no expiry historical facts don't expire). /// Write a full set of candles to cache (no expiry historical facts don't expire).
/// Also updates candle metadata. /// Also updates candle metadata.
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle) void { pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
if (serializeCandles(self.allocator, candles, .{})) |srf_data| { if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
defer self.allocator.free(srf_data); defer self.allocator.free(srf_data);
self.writeRaw(symbol, .candles_daily, srf_data) catch |err| { self.writeRaw(symbol, .candles_daily, srf_data) catch |err| {
@ -227,14 +227,14 @@ pub const Store = struct {
if (candles.len > 0) { if (candles.len > 0) {
const last = candles[candles.len - 1]; const last = candles[candles.len - 1];
self.updateCandleMeta(symbol, last.close, last.date); self.updateCandleMeta(symbol, last.close, last.date, provider, fail_count);
} }
} }
/// Append new candle records to the existing cache file. /// Append new candle records to the existing cache file.
/// Falls back to a full rewrite if append fails (e.g. file doesn't exist). /// Falls back to a full rewrite if append fails (e.g. file doesn't exist).
/// Also updates candle metadata. /// Also updates candle metadata.
pub fn appendCandles(self: *Store, symbol: []const u8, new_candles: []const Candle) void { pub fn appendCandles(self: *Store, symbol: []const u8, new_candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
if (new_candles.len == 0) return; if (new_candles.len == 0) return;
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| { if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
@ -263,21 +263,17 @@ pub const Store = struct {
} }
const last = new_candles[new_candles.len - 1]; const last = new_candles[new_candles.len - 1];
self.updateCandleMeta(symbol, last.close, last.date); self.updateCandleMeta(symbol, last.close, last.date, provider, fail_count);
} }
/// Write (or refresh) candle metadata without touching the candle data file. /// Write (or refresh) candle metadata with a specific provider source.
pub fn updateCandleMeta(self: *Store, symbol: []const u8, last_close: f64, last_date: Date) void { pub fn updateCandleMeta(self: *Store, symbol: []const u8, last_close: f64, last_date: Date, provider: CandleProvider, fail_count: u8) void {
self.updateCandleMetaWithProvider(symbol, last_close, last_date, .twelvedata);
}
/// Write candle metadata with a specific provider source.
pub fn updateCandleMetaWithProvider(self: *Store, symbol: []const u8, last_close: f64, last_date: Date, provider: CandleProvider) void {
const expires = std.time.timestamp() + Ttl.candles_latest; const expires = std.time.timestamp() + Ttl.candles_latest;
const meta = CandleMeta{ const meta = CandleMeta{
.last_close = last_close, .last_close = last_close,
.last_date = last_date, .last_date = last_date,
.provider = provider, .provider = provider,
.fail_count = fail_count,
}; };
if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| { if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| {
defer self.allocator.free(meta_data); defer self.allocator.free(meta_data);
@ -398,8 +394,12 @@ pub const Store = struct {
last_close: f64, last_close: f64,
last_date: Date, last_date: Date,
/// Which provider sourced the candle data. Used during incremental refresh /// Which provider sourced the candle data. Used during incremental refresh
/// to go directly to the right provider instead of trying TwelveData first. /// to go directly to the right provider instead of trying Tiingo first.
provider: CandleProvider = .tiingo, provider: CandleProvider = .tiingo,
/// Consecutive transient failure count for the primary provider (Tiingo).
/// Incremented on ServerError; reset to 0 on success. When >= 3, the
/// symbol is degraded to a fallback provider until Tiingo recovers.
fail_count: u8 = 0,
}; };
pub const CandleProvider = enum { pub const CandleProvider = enum {

View file

@ -39,6 +39,11 @@ pub const DataError = error{
CacheError, CacheError,
ParseError, ParseError,
OutOfMemory, OutOfMemory,
/// Transient provider failure (server error, connection issue).
/// Caller should stop and retry later.
TransientError,
/// Provider auth failure (bad API key). Entire refresh should stop.
AuthError,
}; };
/// Re-exported provider types needed by commands via DataService. /// Re-exported provider types needed by commands via DataService.
@ -263,85 +268,108 @@ pub const DataService = struct {
// Public data methods // Public data methods
/// Fetch candles from providers with fallback logic. /// Fetch candles from providers with error classification.
/// Tries the provider recorded in meta (if any), then Tiingo (primary), then TwelveData, then Yahoo. ///
/// Returns the candles and which provider succeeded. /// Error handling:
/// - ServerError/RateLimited/RequestFailed from Tiingo TransientError (stop refresh, retry later)
/// - NotFound/ParseError/InvalidResponse from Tiingo try Yahoo (symbol-level issue)
/// - Unauthorized TransientError (config problem, stop refresh)
///
/// The `preferred` param controls incremental fetch consistency: use the same
/// provider that sourced the existing cache data.
fn fetchCandlesFromProviders( fn fetchCandlesFromProviders(
self: *DataService, self: *DataService,
symbol: []const u8, symbol: []const u8,
from: Date, from: Date,
to: Date, to: Date,
preferred: cache.Store.CandleProvider, preferred: cache.Store.CandleProvider,
) !struct { candles: []Candle, provider: cache.Store.CandleProvider } { ) (DataError || error{NotFound})!struct { candles: []Candle, provider: cache.Store.CandleProvider } {
// If preferred is Yahoo, try it first // If preferred is Yahoo (degraded symbol), try Yahoo first
if (preferred == .yahoo) { if (preferred == .yahoo) {
if (self.getProvider(Yahoo)) |yh| { if (self.getProvider(Yahoo)) |yh| {
if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| { if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Yahoo (preferred)", .{symbol}); log.debug("{s}: candles from Yahoo (preferred)", .{symbol});
return .{ .candles = candles, .provider = .yahoo }; return .{ .candles = candles, .provider = .yahoo };
} else |_| {}
} else |_| {}
}
// If preferred is TwelveData, try it before Tiingo
if (preferred == .twelvedata) {
if (self.getProvider(TwelveData)) |td| {
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (preferred)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |err| { } else |err| {
if (err == error.RateLimited) { log.warn("{s}: Yahoo (preferred) failed: {s}", .{ symbol, @errorName(err) });
self.rateLimitBackoff();
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (preferred, after rate limit retry)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |_| {}
}
} }
} else |_| {} } else |_| {}
} }
// Primary: Tiingo (1000 req/day, no per-minute limit) // Primary: Tiingo
if (self.getProvider(Tiingo)) |tg| { if (self.getProvider(Tiingo)) |tg| {
if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| { if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Tiingo", .{symbol}); log.debug("{s}: candles from Tiingo", .{symbol});
return .{ .candles = candles, .provider = .tiingo }; return .{ .candles = candles, .provider = .tiingo };
} else |_| {}
} else |_| {}
// Fallback: TwelveData (if not already tried as preferred)
if (preferred != .twelvedata) {
if (self.getProvider(TwelveData)) |td| {
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (fallback)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |err| { } else |err| {
if (err == error.RateLimited) { log.warn("{s}: Tiingo failed: {s}", .{ symbol, @errorName(err) });
self.rateLimitBackoff();
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| { if (err == error.Unauthorized) {
log.debug("{s}: candles from TwelveData (fallback, after rate limit retry)", .{symbol}); log.err("{s}: Tiingo auth failed — check TIINGO_API_KEY", .{symbol});
return .{ .candles = candles, .provider = .twelvedata }; return DataError.AuthError;
} else |_| {}
}
}
} else |_| {}
} }
// Last resort: Yahoo (if not already tried as preferred) if (err == error.RateLimited) {
// Rate limited: back off and retry this is expected, not a failure
log.info("{s}: Tiingo rate limited, backing off", .{symbol});
self.rateLimitBackoff();
if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Tiingo (after rate limit backoff)", .{symbol});
return .{ .candles = candles, .provider = .tiingo };
} else |retry_err| {
log.warn("{s}: Tiingo retry after backoff failed: {s}", .{ symbol, @errorName(retry_err) });
if (retry_err == error.RateLimited) {
// Still rate limited after backoff one more try
self.rateLimitBackoff();
if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Tiingo (after second backoff)", .{symbol});
return .{ .candles = candles, .provider = .tiingo };
} else |_| {}
}
// Exhausted rate limit retries treat as transient
return DataError.TransientError;
}
}
if (isTransientError(err)) {
// Server error or connection failure stop, don't fall back
return DataError.TransientError;
}
// NotFound, ParseError, InvalidResponse symbol-level issue, try Yahoo
log.info("{s}: Tiingo does not have this symbol, trying Yahoo", .{symbol});
}
} else |_| {
log.warn("{s}: Tiingo provider not available (no API key?)", .{symbol});
}
// Fallback: Yahoo (symbol not on Tiingo)
if (preferred != .yahoo) { if (preferred != .yahoo) {
if (self.getProvider(Yahoo)) |yh| { if (self.getProvider(Yahoo)) |yh| {
if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| { if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Yahoo (fallback)", .{symbol}); log.info("{s}: candles from Yahoo (Tiingo fallback)", .{symbol});
return .{ .candles = candles, .provider = .yahoo }; return .{ .candles = candles, .provider = .yahoo };
} else |_| {} } else |err| {
} else |_| {} log.warn("{s}: Yahoo fallback also failed: {s}", .{ symbol, @errorName(err) });
}
} else |_| {
log.warn("{s}: Yahoo provider not available", .{symbol});
}
} }
return error.FetchFailed; return DataError.FetchFailed;
}
/// Classify whether a provider error is transient (provider is down).
/// ServerError = HTTP 5xx, RequestFailed = connection/network failure.
/// Note: RateLimited and Unauthorized are handled separately.
fn isTransientError(err: anyerror) bool {
return err == error.ServerError or
err == error.RequestFailed;
} }
/// Fetch daily candles for a symbol (10+ years for trailing returns). /// Fetch daily candles for a symbol (10+ years for trailing returns).
/// Checks cache first; fetches from Tiingo (primary), TwelveData, or Yahoo if stale/missing. /// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
/// Uses incremental updates: when the cache is stale, only fetches /// Uses incremental updates: when the cache is stale, only fetches
/// candles newer than the last cached date rather than re-fetching /// candles newer than the last cached date rather than re-fetching
/// the entire history. /// the entire history.
@ -353,13 +381,17 @@ pub const DataService = struct {
const meta_result = s.readCandleMeta(symbol); const meta_result = s.readCandleMeta(symbol);
if (meta_result) |mr| { if (meta_result) |mr| {
const m = mr.meta; const m = mr.meta;
if (s.isCandleMetaFresh(symbol)) {
// If cached data is from TwelveData (deprecated for candles due to
// unreliable adj_close), skip cache and fall through to full re-fetch.
if (m.provider == .twelvedata) {
log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol});
} else if (s.isCandleMetaFresh(symbol)) {
// Fresh deserialize candles and return // Fresh deserialize candles and return
log.debug("{s}: candles fresh in local cache", .{symbol}); log.debug("{s}: candles fresh in local cache", .{symbol});
if (s.read(Candle, symbol, null, .any)) |r| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
} } else {
// Stale try server sync before incremental fetch // Stale try server sync before incremental fetch
if (self.syncCandlesFromServer(symbol)) { if (self.syncCandlesFromServer(symbol)) {
if (s.isCandleMetaFresh(symbol)) { if (s.isCandleMetaFresh(symbol)) {
@ -368,7 +400,6 @@ pub const DataService = struct {
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} }
log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol}); log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol});
// Server data also stale fall through to incremental fetch
} }
// Stale try incremental update using last_date from meta // Stale try incremental update using last_date from meta
@ -376,13 +407,27 @@ pub const DataService = struct {
// If last cached date is today or later, just refresh the TTL (meta only) // If last cached date is today or later, just refresh the TTL (meta only)
if (!fetch_from.lessThan(today)) { if (!fetch_from.lessThan(today)) {
s.updateCandleMeta(symbol, m.last_close, m.last_date); s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count);
if (s.read(Candle, symbol, null, .any)) |r| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} else { } else {
// Incremental fetch from day after last cached candle // Incremental fetch from day after last cached candle
const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch { const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| {
// All providers failed return stale data if (err == DataError.TransientError) {
// Increment fail_count for this symbol
const new_fail_count = m.fail_count +| 1; // saturating add
log.warn("{s}: transient failure (fail_count now {d})", .{ symbol, new_fail_count });
s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, new_fail_count);
// If degraded (fail_count >= 3), return stale data rather than failing
if (new_fail_count >= 3) {
log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count });
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
}
return DataError.TransientError;
}
// Non-transient failure return stale data if available
if (s.read(Candle, symbol, null, .any)) |r| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.FetchFailed; return DataError.FetchFailed;
@ -390,24 +435,23 @@ pub const DataService = struct {
const new_candles = result.candles; const new_candles = result.candles;
if (new_candles.len == 0) { if (new_candles.len == 0) {
// No new candles (weekend/holiday) refresh TTL only (meta rewrite) // No new candles (weekend/holiday) refresh TTL, reset fail_count
self.allocator.free(new_candles); self.allocator.free(new_candles);
s.updateCandleMetaWithProvider(symbol, m.last_close, m.last_date, result.provider); s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0);
if (s.read(Candle, symbol, null, .any)) |r| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} else { } else {
// Append new candles to existing file + update meta // Append new candles to existing file + update meta, reset fail_count
s.appendCandles(symbol, new_candles); s.appendCandles(symbol, new_candles, result.provider, 0);
// Load the full (now-updated) file for the caller
if (s.read(Candle, symbol, null, .any)) |r| { if (s.read(Candle, symbol, null, .any)) |r| {
self.allocator.free(new_candles); self.allocator.free(new_candles);
return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp() };
} }
// Append failed or file unreadable just return new candles
return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() }; return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() };
} }
} }
} }
}
// No usable cache try server sync first // No usable cache try server sync first
if (self.syncCandlesFromServer(symbol)) { if (self.syncCandlesFromServer(symbol)) {
@ -417,23 +461,27 @@ pub const DataService = struct {
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} }
log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol}); log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol});
// Server data also stale fall through to full fetch
} }
// No usable cache full fetch (~10 years, plus buffer for leap years) // No usable cache full fetch (~10 years, plus buffer for leap years)
log.debug("{s}: fetching full candle history from provider", .{symbol}); log.debug("{s}: fetching full candle history from provider", .{symbol});
const from = today.addDays(-3700); const from = today.addDays(-3700);
const result = self.fetchCandlesFromProviders(symbol, from, today, .tiingo) catch { const result = self.fetchCandlesFromProviders(symbol, from, today, .tiingo) catch |err| {
if (err == DataError.TransientError) {
// On a fresh fetch, increment fail_count if we have meta
if (meta_result) |mr| {
const new_fail_count = mr.meta.fail_count +| 1;
s.updateCandleMeta(symbol, mr.meta.last_close, mr.meta.last_date, mr.meta.provider, new_fail_count);
}
return DataError.TransientError;
}
s.writeNegative(symbol, .candles_daily); s.writeNegative(symbol, .candles_daily);
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
if (result.candles.len > 0) { if (result.candles.len > 0) {
s.cacheCandles(symbol, result.candles); s.cacheCandles(symbol, result.candles, result.provider, 0); // reset fail_count on success
// Record which provider sourced this data
const last = result.candles[result.candles.len - 1];
s.updateCandleMetaWithProvider(symbol, last.close, last.date, result.provider);
} }
return .{ .data = result.candles, .source = .fetched, .timestamp = std.time.timestamp() }; return .{ .data = result.candles, .source = .fetched, .timestamp = std.time.timestamp() };
@ -596,10 +644,9 @@ 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 returns are the primary total return source (accounts for // adj_close is the primary total return source (accounts for splits +
// splits + dividends). Dividend-reinvestment is only used as a fallback // dividends). Dividend-reinvestment only fills gaps where adj_close
// for periods where adj_close returns null (e.g. candle history too short // returns null (e.g. stable-NAV funds with short candle history).
// for stable-NAV funds like money markets).
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); 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);