switch earnings cache ttl to 30 days + day after earnings

This commit is contained in:
Emil Lerch 2026-03-06 15:24:24 -08:00
parent 78a15b89be
commit 6a680a2381
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 22 additions and 6 deletions

View file

@ -40,7 +40,7 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | | Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | | Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days |
| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour | | Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour |
| Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 24 hours | | Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 30 days* |
| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days | | ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days |
### TwelveData ### TwelveData
@ -127,10 +127,12 @@ Cache files use [SRF](https://github.com/lobo/srf) (Simple Record Format), a lin
| Dividends | 7 days | Declared well in advance | | Dividends | 7 days | Declared well in advance |
| Splits | 7 days | Rare corporate events | | Splits | 7 days | Rare corporate events |
| Options | 1 hour | Prices change continuously during market hours | | Options | 1 hour | Prices change continuously during market hours |
| Earnings | 24 hours | Quarterly events, estimates update periodically | | Earnings | 30 days* | Quarterly events; smart refresh after announcements |
| ETF profiles | 30 days | Holdings/weights change slowly | | ETF profiles | 30 days | Holdings/weights change slowly |
| Quotes | Never cached | Intended for live price checks | | Quotes | Never cached | Intended for live price checks |
\* **Earnings smart refresh:** Even within the 30-day TTL, cached earnings are automatically re-fetched when an earnings date has passed but the cache still has no actual results for it. This ensures results appear promptly after an announcement without wasteful daily polling.
Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching. Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching.
### Rate limiting ### Rate limiting

4
src/cache/store.zig vendored
View file

@ -28,8 +28,8 @@ pub const Ttl = struct {
pub const splits: i64 = 7 * 24 * 3600; pub const splits: i64 = 7 * 24 * 3600;
/// Options chains refresh hourly /// Options chains refresh hourly
pub const options: i64 = 3600; pub const options: i64 = 3600;
/// Earnings refresh daily /// Earnings refresh monthly, with smart refresh after announcements
pub const earnings: i64 = 24 * 3600; pub const earnings: i64 = 30 * 24 * 3600;
/// ETF profiles refresh monthly /// ETF profiles refresh monthly
pub const etf_profile: i64 = 30 * 24 * 3600; pub const etf_profile: i64 = 30 * 24 * 3600;
}; };

View file

@ -248,20 +248,34 @@ pub const DataService = struct {
/// Fetch earnings history for a symbol (5 years back, 1 year forward). /// Fetch earnings history for a symbol (5 years back, 1 year forward).
/// Checks cache first; fetches from Finnhub if stale/missing. /// Checks cache first; fetches from Finnhub if stale/missing.
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
/// date has no actual results yet (i.e. results just came out).
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!struct { data: []EarningsEvent, source: Source, timestamp: i64 } { pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!struct { data: []EarningsEvent, source: Source, timestamp: i64 } {
var s = self.store(); var s = self.store();
const today = todayDate();
const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError; const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError;
if (cached_raw) |data| { if (cached_raw) |data| {
defer self.allocator.free(data); defer self.allocator.free(data);
if (cache.Store.isFreshData(data, self.allocator)) { if (cache.Store.isFreshData(data, self.allocator)) {
const events = cache.Store.deserializeEarnings(self.allocator, data) catch null; const events = cache.Store.deserializeEarnings(self.allocator, data) catch null;
if (events) |e| return .{ .data = e, .source = .cached, .timestamp = s.getMtime(symbol, .earnings) orelse std.time.timestamp() }; if (events) |e| {
// Check if any past/today earnings event is still missing actual results.
// If so, the announcement likely just happened force a refresh.
const needs_refresh = for (e) |ev| {
if (ev.actual == null and !today.lessThan(ev.date)) break true;
} else false;
if (!needs_refresh) {
return .{ .data = e, .source = .cached, .timestamp = s.getMtime(symbol, .earnings) orelse std.time.timestamp() };
}
// Stale: free cached events and re-fetch below
self.allocator.free(e);
}
} }
} }
var fh = try self.getFinnhub(); var fh = try self.getFinnhub();
const today = todayDate();
const from = today.subtractYears(5); const from = today.subtractYears(5);
const to = today.addDays(365); const to = today.addDays(365);