retry logic on 429

This commit is contained in:
Emil Lerch 2026-03-10 08:37:00 -07:00
parent e0129003e6
commit 7eba504ed8
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 201 additions and 128 deletions

27
src/cache/store.zig vendored
View file

@ -57,6 +57,17 @@ pub const DataType = enum {
.meta => "meta.srf", .meta => "meta.srf",
}; };
} }
pub fn ttl(self: DataType) i64 {
return switch (self) {
.dividends => Ttl.dividends,
.splits => Ttl.splits,
.options => Ttl.options,
.earnings => Ttl.earnings,
.etf_profile => Ttl.etf_profile,
.candles_daily, .candles_meta, .meta => 0,
};
}
}; };
/// Persistent SRF-backed cache with per-symbol, per-data-type files. /// Persistent SRF-backed cache with per-symbol, per-data-type files.
@ -84,7 +95,7 @@ pub const Store = struct {
// Generic typed API // Generic typed API
/// Map a model type to its cache DataType. /// Map a model type to its cache DataType.
fn dataTypeFor(comptime T: type) DataType { pub fn dataTypeFor(comptime T: type) DataType {
return switch (T) { return switch (T) {
Candle => .candles_daily, Candle => .candles_daily,
Dividend => .dividends, Dividend => .dividends,
@ -97,7 +108,7 @@ pub const Store = struct {
} }
/// The data payload for a given type: single struct for EtfProfile, slice for everything else. /// The data payload for a given type: single struct for EtfProfile, slice for everything else.
fn DataFor(comptime T: type) type { pub fn DataFor(comptime T: type) type {
return if (T == EtfProfile) EtfProfile else []T; return if (T == EtfProfile) EtfProfile else []T;
} }
@ -121,6 +132,18 @@ pub const Store = struct {
defer self.allocator.free(data); defer self.allocator.free(data);
if (T == EtfProfile or T == OptionsChain) { if (T == EtfProfile or T == OptionsChain) {
const is_negative = std.mem.eql(u8, data, negative_cache_content);
if (is_negative) {
if (freshness == .fresh_only) {
// Negative entries are always fresh return empty data
if (T == EtfProfile)
return .{ .data = EtfProfile{ .symbol = "" }, .timestamp = std.time.timestamp() };
if (T == OptionsChain)
return .{ .data = &.{}, .timestamp = std.time.timestamp() };
}
return null;
}
var reader = std.Io.Reader.fixed(data); var reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null; var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
defer it.deinit(); defer it.deinit();

View file

@ -18,6 +18,7 @@ const Quote = @import("models/quote.zig").Quote;
const EtfProfile = @import("models/etf_profile.zig").EtfProfile; const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const cache = @import("cache/store.zig"); const cache = @import("cache/store.zig");
const srf = @import("srf");
const TwelveData = @import("providers/twelvedata.zig").TwelveData; const TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon; const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub; const Finnhub = @import("providers/finnhub.zig").Finnhub;
@ -47,6 +48,15 @@ pub const Source = enum {
fetched, fetched,
}; };
/// Generic result type for all fetch operations: data payload + provenance metadata.
pub fn FetchResult(comptime T: type) type {
return struct {
data: cache.Store.DataFor(T),
source: Source,
timestamp: i64,
};
}
// PostProcess callbacks // PostProcess callbacks
// These are passed to Store.read to handle type-specific // These are passed to Store.read to handle type-specific
// concerns: string duping (serialization plumbing) and domain transforms. // concerns: string duping (serialization plumbing) and domain transforms.
@ -95,40 +105,40 @@ pub const DataService = struct {
if (self.av) |*av| av.deinit(); if (self.av) |*av| av.deinit();
} }
// Provider accessors // Provider accessor
fn getTwelveData(self: *DataService) DataError!*TwelveData { fn getProvider(self: *DataService, comptime T: type) DataError!*T {
if (self.td) |*td| return td; const field_name = comptime providerField(T);
const key = self.config.twelvedata_key orelse return DataError.NoApiKey; if (@field(self, field_name)) |*p| return p;
self.td = TwelveData.init(self.allocator, key); if (T == Cboe) {
return &self.td.?; // CBOE has no key
@field(self, field_name) = T.init(self.allocator);
} else {
// All we're doing here is lower casing the type name, then
// appending _key to it, so AlphaVantage -> alphavantage_key
const config_key = comptime blk: {
const full = @typeName(T);
var start: usize = 0;
for (full, 0..) |c, i| {
if (c == '.') start = i + 1;
}
const short = full[start..];
var buf: [short.len + 4]u8 = undefined;
_ = std.ascii.lowerString(buf[0..short.len], short);
@memcpy(buf[short.len..][0..4], "_key");
break :blk buf[0 .. short.len + 4];
};
const key = @field(self.config, config_key) orelse return DataError.NoApiKey;
@field(self, field_name) = T.init(self.allocator, key);
}
return &@field(self, field_name).?;
} }
fn getPolygon(self: *DataService) DataError!*Polygon { fn providerField(comptime T: type) []const u8 {
if (self.pg) |*pg| return pg; inline for (std.meta.fields(DataService)) |f| {
const key = self.config.polygon_key orelse return DataError.NoApiKey; if (f.type == ?T) return f.name;
self.pg = Polygon.init(self.allocator, key); }
return &self.pg.?; @compileError("unknown provider type");
}
fn getFinnhub(self: *DataService) DataError!*Finnhub {
if (self.fh) |*fh| return fh;
const key = self.config.finnhub_key orelse return DataError.NoApiKey;
self.fh = Finnhub.init(self.allocator, key);
return &self.fh.?;
}
fn getCboe(self: *DataService) *Cboe {
if (self.cboe) |*c| return c;
self.cboe = Cboe.init(self.allocator);
return &self.cboe.?;
}
fn getAlphaVantage(self: *DataService) DataError!*AlphaVantage {
if (self.av) |*av| return av;
const key = self.config.alphavantage_key orelse return DataError.NoApiKey;
self.av = AlphaVantage.init(self.allocator, key);
return &self.av.?;
} }
// Cache helper // Cache helper
@ -137,6 +147,59 @@ pub const DataService = struct {
return cache.Store.init(self.allocator, self.config.cache_dir); return cache.Store.init(self.allocator, self.config.cache_dir);
} }
/// Generic fetch-or-cache for simple data types (dividends, splits, options).
/// Checks cache first; on miss, fetches from the appropriate provider,
/// writes to cache, and returns. On permanent fetch failure, writes a negative
/// cache entry. Rate limit failures are retried once.
fn fetchCached(
self: *DataService,
comptime T: type,
symbol: []const u8,
comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
) DataError!FetchResult(T) {
var s = self.store();
const data_type = comptime cache.Store.dataTypeFor(T);
if (s.read(T, symbol, postProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
if (err == error.RateLimited) {
// Wait and retry once
self.rateLimitBackoff();
const retried = self.fetchFromProvider(T, symbol) catch {
return DataError.FetchFailed;
};
s.write(T, symbol, retried, data_type.ttl());
return .{ .data = retried, .source = .fetched, .timestamp = std.time.timestamp() };
}
s.writeNegative(symbol, data_type);
return DataError.FetchFailed;
};
s.write(T, symbol, fetched, data_type.ttl());
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
}
/// Dispatch a fetch to the correct provider based on model type.
fn fetchFromProvider(self: *DataService, comptime T: type, symbol: []const u8) !cache.Store.DataFor(T) {
return switch (T) {
Dividend => {
var pg = try self.getProvider(Polygon);
return pg.fetchDividends(self.allocator, symbol, null, null);
},
Split => {
var pg = try self.getProvider(Polygon);
return pg.fetchSplits(self.allocator, symbol);
},
OptionsChain => {
var cboe = try self.getProvider(Cboe);
return cboe.fetchOptionsChain(self.allocator, symbol);
},
else => @compileError("unsupported type for fetchFromProvider"),
};
}
/// Invalidate cached data for a symbol so the next get* call forces a fresh fetch. /// Invalidate cached data for a symbol so the next get* call forces a fresh fetch.
pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void { pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void {
var s = self.store(); var s = self.store();
@ -154,7 +217,7 @@ pub const DataService = struct {
/// 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.
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!struct { data: []Candle, source: Source, timestamp: i64 } { pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) {
var s = self.store(); var s = self.store();
const today = todayDate(); const today = todayDate();
@ -178,14 +241,22 @@ pub const DataService = struct {
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
var td = self.getTwelveData() catch { var td = self.getProvider(TwelveData) catch {
// No API key return stale data // No API key return stale data
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.NoApiKey; return DataError.NoApiKey;
}; };
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch { const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch |err| blk: {
// Fetch failed return stale data rather than erroring if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.FetchFailed;
};
}
// Non-rate-limit failure return stale data
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;
@ -212,10 +283,17 @@ pub const DataService = struct {
} }
// No usable cache full fetch (~10 years, plus buffer for leap years) // No usable cache full fetch (~10 years, plus buffer for leap years)
var td = try self.getTwelveData(); var td = try self.getProvider(TwelveData);
const from = today.addDays(-3700); const from = today.addDays(-3700);
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch { const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk td.fetchCandles(self.allocator, symbol, from, today) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .candles_daily);
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
@ -227,68 +305,25 @@ pub const DataService = struct {
} }
/// Fetch dividend history for a symbol. /// Fetch dividend history for a symbol.
/// Checks cache first; fetches from Polygon if stale/missing. pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } { return self.fetchCached(Dividend, symbol, dividendPostProcess);
var s = self.store();
if (s.read(Dividend, symbol, dividendPostProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var pg = try self.getPolygon();
const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch {
return DataError.FetchFailed;
};
if (fetched.len > 0) {
s.write(Dividend, symbol, fetched, cache.Ttl.dividends);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
} }
/// Fetch split history for a symbol. /// Fetch split history for a symbol.
/// Checks cache first; fetches from Polygon if stale/missing. pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } { return self.fetchCached(Split, symbol, null);
var s = self.store();
if (s.read(Split, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var pg = try self.getPolygon();
const fetched = pg.fetchSplits(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
s.write(Split, symbol, fetched, cache.Ttl.splits);
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
} }
/// Fetch options chain for a symbol (all expirations). /// Fetch options chain for a symbol (all expirations, no API key needed).
/// Checks cache first; fetches from CBOE if stale/missing (no API key needed). pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } { return self.fetchCached(OptionsChain, symbol, null);
var s = self.store();
if (s.read(OptionsChain, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var cboe = self.getCboe();
const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
if (fetched.len > 0) {
s.write(OptionsChain, symbol, fetched, cache.Ttl.options);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
} }
/// 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 /// 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). /// 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!FetchResult(EarningsEvent) {
var s = self.store(); var s = self.store();
const today = todayDate(); const today = todayDate();
@ -306,31 +341,43 @@ pub const DataService = struct {
self.allocator.free(cached.data); self.allocator.free(cached.data);
} }
var fh = try self.getFinnhub(); var fh = try self.getProvider(Finnhub);
const from = today.subtractYears(5); const from = today.subtractYears(5);
const to = today.addDays(365); const to = today.addDays(365);
const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch { const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk fh.fetchEarnings(self.allocator, symbol, from, to) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .earnings);
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
if (fetched.len > 0) { s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);
s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
} }
/// Fetch ETF profile for a symbol. /// Fetch ETF profile for a symbol.
/// Checks cache first; fetches from Alpha Vantage if stale/missing. /// Checks cache first; fetches from Alpha Vantage if stale/missing.
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } { pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) {
var s = self.store(); var s = self.store();
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var av = try self.getAlphaVantage(); var av = try self.getProvider(AlphaVantage);
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch { const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk av.fetchEtfProfile(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .etf_profile);
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
@ -342,7 +389,7 @@ pub const DataService = struct {
/// Fetch a real-time (or 15-min delayed) quote for a symbol. /// Fetch a real-time (or 15-min delayed) quote for a symbol.
/// No cache -- always fetches fresh from TwelveData. /// No cache -- always fetches fresh from TwelveData.
pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote { pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote {
var td = try self.getTwelveData(); var td = try self.getProvider(TwelveData);
return td.fetchQuote(self.allocator, symbol) catch return td.fetchQuote(self.allocator, symbol) catch
return DataError.FetchFailed; return DataError.FetchFailed;
} }
@ -350,7 +397,7 @@ pub const DataService = struct {
/// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage. /// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage.
/// No cache -- always fetches fresh. Caller must free the returned string fields. /// No cache -- always fetches fresh. Caller must free the returned string fields.
pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview { pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview {
var av = try self.getAlphaVantage(); var av = try self.getProvider(AlphaVantage);
return av.fetchCompanyOverview(self.allocator, symbol) catch return av.fetchCompanyOverview(self.allocator, symbol) catch
return DataError.FetchFailed; return DataError.FetchFailed;
} }
@ -602,6 +649,12 @@ pub const DataService = struct {
return null; return null;
} }
/// A single CUSIP-to-ticker mapping record in the cache file.
const CusipEntry = struct {
cusip: []const u8 = "",
ticker: []const u8 = "",
};
/// Read a cached CUSIP->ticker mapping. Returns null if not cached. /// Read a cached CUSIP->ticker mapping. Returns null if not cached.
/// Caller owns the returned string. /// Caller owns the returned string.
fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 { fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 {
@ -611,30 +664,14 @@ pub const DataService = struct {
const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null; const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null;
defer self.allocator.free(data); defer self.allocator.free(data);
// Simple line-based format: cusip::XXXXX,ticker::YYYYY var reader = std.Io.Reader.fixed(data);
var lines = std.mem.splitScalar(u8, data, '\n'); var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
while (lines.next()) |line| { defer it.deinit();
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
// Parse cusip:: field while (it.next() catch return null) |fields| {
const cusip_prefix = "cusip::"; const entry = fields.to(CusipEntry) catch continue;
if (!std.mem.startsWith(u8, trimmed, cusip_prefix)) continue; if (std.mem.eql(u8, entry.cusip, cusip) and entry.ticker.len > 0) {
const after_cusip = trimmed[cusip_prefix.len..]; return self.allocator.dupe(u8, entry.ticker) catch null;
const comma_idx = std.mem.indexOfScalar(u8, after_cusip, ',') orelse continue;
const cached_cusip = after_cusip[0..comma_idx];
if (!std.mem.eql(u8, cached_cusip, cusip)) continue;
// Parse ticker:: field
const rest = after_cusip[comma_idx + 1 ..];
const ticker_prefix = "ticker::";
if (!std.mem.startsWith(u8, rest, ticker_prefix)) continue;
const ticker_val = rest[ticker_prefix.len..];
// Trim any trailing comma/fields
const ticker_end = std.mem.indexOfScalar(u8, ticker_val, ',') orelse ticker_val.len;
const ticker = ticker_val[0..ticker_end];
if (ticker.len > 0) {
return self.allocator.dupe(u8, ticker) catch null;
} }
} }
return null; return null;
@ -650,21 +687,34 @@ pub const DataService = struct {
std.fs.cwd().makePath(dir) catch {}; std.fs.cwd().makePath(dir) catch {};
} }
// Append the mapping // Open existing (append) or create new (with header)
var emit_directives = false;
const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: { const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: {
// File doesn't exist, create it emit_directives = true;
break :blk std.fs.cwd().createFile(path, .{}) catch return; break :blk std.fs.cwd().createFile(path, .{}) catch return;
}; };
defer file.close(); defer file.close();
file.seekFromEnd(0) catch {}; if (!emit_directives) file.seekFromEnd(0) catch {};
const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "cusip::{s},ticker::{s}\n", .{ cusip, ticker }) catch return; var writer = file.writer(&buf);
_ = file.write(line) catch {}; writer.interface.print("{f}", .{srf.fmtFrom(CusipEntry, self.allocator, &entry, .{ .emit_directives = emit_directives })}) catch return;
writer.interface.flush() catch {};
} }
// Utility // Utility
/// Sleep before retrying after a rate limit error.
/// Uses the provider's rate limiter estimate if available, otherwise a fixed 10s backoff.
fn rateLimitBackoff(self: *DataService) void {
const wait_ns: u64 = if (self.td) |*td|
@max(td.rate_limiter.estimateWaitNs(), 2 * std.time.ns_per_s)
else
10 * std.time.ns_per_s;
std.Thread.sleep(wait_ns);
}
fn todayDate() Date { fn todayDate() Date {
const ts = std.time.timestamp(); const ts = std.time.timestamp();
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day)); const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));