diff --git a/src/cache/store.zig b/src/cache/store.zig index a0f3681..f191885 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const log = std.log.scoped(.cache); const srf = @import("srf"); const Date = @import("../models/date.zig").Date; const Candle = @import("../models/candle.zig").Candle; @@ -179,20 +180,35 @@ pub const Store = struct { const expires = std.time.timestamp() + ttl; const data_type = dataTypeFor(T); if (T == EtfProfile) { - const srf_data = serializeEtfProfile(self.allocator, items, .{ .expires = expires }) catch return; + const srf_data = serializeEtfProfile(self.allocator, items, .{ .expires = expires }) catch |err| { + log.warn("{s}: failed to serialize ETF profile: {s}", .{ symbol, @errorName(err) }); + return; + }; defer self.allocator.free(srf_data); - self.writeRaw(symbol, data_type, srf_data) catch {}; + self.writeRaw(symbol, data_type, srf_data) catch |err| { + log.warn("{s}: failed to write ETF profile to cache: {s}", .{ symbol, @errorName(err) }); + }; return; } if (T == OptionsChain) { - const srf_data = serializeOptions(self.allocator, items, .{ .expires = expires }) catch return; + const srf_data = serializeOptions(self.allocator, items, .{ .expires = expires }) catch |err| { + log.warn("{s}: failed to serialize options: {s}", .{ symbol, @errorName(err) }); + return; + }; defer self.allocator.free(srf_data); - self.writeRaw(symbol, data_type, srf_data) catch {}; + self.writeRaw(symbol, data_type, srf_data) catch |err| { + log.warn("{s}: failed to write options to cache: {s}", .{ symbol, @errorName(err) }); + }; return; } - const srf_data = serializeWithMeta(T, self.allocator, items, .{ .expires = expires }) catch return; + const srf_data = serializeWithMeta(T, self.allocator, items, .{ .expires = expires }) catch |err| { + log.warn("{s}: failed to serialize {s}: {s}", .{ symbol, @tagName(data_type), @errorName(err) }); + return; + }; defer self.allocator.free(srf_data); - self.writeRaw(symbol, data_type, srf_data) catch {}; + self.writeRaw(symbol, data_type, srf_data) catch |err| { + log.warn("{s}: failed to write {s} to cache: {s}", .{ symbol, @tagName(data_type), @errorName(err) }); + }; } // ── Candle-specific API ────────────────────────────────────── @@ -202,8 +218,12 @@ pub const Store = struct { pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle) void { if (serializeCandles(self.allocator, candles, .{})) |srf_data| { defer self.allocator.free(srf_data); - self.writeRaw(symbol, .candles_daily, srf_data) catch {}; - } else |_| {} + self.writeRaw(symbol, .candles_daily, srf_data) catch |err| { + log.warn("{s}: failed to write candles to cache: {s}", .{ symbol, @errorName(err) }); + }; + } else |err| { + log.warn("{s}: failed to serialize candles: {s}", .{ symbol, @errorName(err) }); + } if (candles.len > 0) { const last = candles[candles.len - 1]; @@ -219,8 +239,9 @@ pub const Store = struct { if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| { defer self.allocator.free(srf_data); - self.appendRaw(symbol, .candles_daily, srf_data) catch { + self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| { // Append failed (file missing?) — fall back to full load + rewrite + log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) }); if (self.read(Candle, symbol, null, .any)) |existing| { defer self.allocator.free(existing.data); const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return; @@ -229,11 +250,17 @@ pub const Store = struct { @memcpy(merged[existing.data.len..], new_candles); if (serializeCandles(self.allocator, merged, .{})) |full_data| { defer self.allocator.free(full_data); - self.writeRaw(symbol, .candles_daily, full_data) catch {}; - } else |_| {} + self.writeRaw(symbol, .candles_daily, full_data) catch |err| { + log.warn("{s}: failed to write merged candles to cache: {s}", .{ symbol, @errorName(err) }); + }; + } else |err| { + log.warn("{s}: failed to serialize merged candles: {s}", .{ symbol, @errorName(err) }); + } } }; - } else |_| {} + } else |err| { + log.warn("{s}: failed to serialize new candles for append: {s}", .{ symbol, @errorName(err) }); + } const last = new_candles[new_candles.len - 1]; self.updateCandleMeta(symbol, last.close, last.date); @@ -254,8 +281,12 @@ pub const Store = struct { }; if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| { defer self.allocator.free(meta_data); - self.writeRaw(symbol, .candles_meta, meta_data) catch {}; - } else |_| {} + self.writeRaw(symbol, .candles_meta, meta_data) catch |err| { + log.warn("{s}: failed to write candle metadata: {s}", .{ symbol, @errorName(err) }); + }; + } else |err| { + log.warn("{s}: failed to serialize candle metadata: {s}", .{ symbol, @errorName(err) }); + } } // ── Cache management ───────────────────────────────────────── @@ -931,3 +962,101 @@ test "portfolio: price_ratio round-trip" { try std.testing.expectEqualStrings("VTTHX", portfolio2.lots[0].ticker.?); try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio2.lots[1].price_ratio, 0.001); } + +// ── TTL and Negative Cache Tests ───────────────────────────────── + +test "TTL constants are reasonable" { + // Historical candles never expire + try std.testing.expectEqual(@as(i64, -1), Ttl.candles_historical); + + // Latest candles expire just under 24 hours (allowing for cron jitter) + try std.testing.expect(Ttl.candles_latest > 23 * std.time.s_per_hour); + try std.testing.expect(Ttl.candles_latest < 24 * std.time.s_per_hour); + + // Dividends and splits refresh biweekly + try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.dividends); + try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.splits); + + // Options refresh hourly + try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options); + + // Earnings and ETF profiles refresh monthly + try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.earnings); + try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.etf_profile); +} + +test "DataType.ttl returns correct values" { + try std.testing.expectEqual(Ttl.dividends, DataType.dividends.ttl()); + try std.testing.expectEqual(Ttl.splits, DataType.splits.ttl()); + try std.testing.expectEqual(Ttl.options, DataType.options.ttl()); + try std.testing.expectEqual(Ttl.earnings, DataType.earnings.ttl()); + try std.testing.expectEqual(Ttl.etf_profile, DataType.etf_profile.ttl()); + + // These types have no TTL (0 = managed elsewhere) + try std.testing.expectEqual(@as(i64, 0), DataType.candles_daily.ttl()); + try std.testing.expectEqual(@as(i64, 0), DataType.candles_meta.ttl()); + try std.testing.expectEqual(@as(i64, 0), DataType.meta.ttl()); +} + +test "DataType.fileName returns correct file names" { + try std.testing.expectEqualStrings("candles_daily.srf", DataType.candles_daily.fileName()); + try std.testing.expectEqualStrings("candles_meta.srf", DataType.candles_meta.fileName()); + try std.testing.expectEqualStrings("dividends.srf", DataType.dividends.fileName()); + try std.testing.expectEqualStrings("splits.srf", DataType.splits.fileName()); + try std.testing.expectEqualStrings("options.srf", DataType.options.fileName()); + try std.testing.expectEqualStrings("earnings.srf", DataType.earnings.fileName()); + try std.testing.expectEqualStrings("etf_profile.srf", DataType.etf_profile.fileName()); + try std.testing.expectEqualStrings("meta.srf", DataType.meta.fileName()); +} + +test "negative_cache_content format" { + // Negative cache marker should be valid SRF with a comment + try std.testing.expect(std.mem.startsWith(u8, Store.negative_cache_content, "#!srfv1")); + try std.testing.expect(std.mem.indexOf(u8, Store.negative_cache_content, "fetch_failed") != null); +} + +test "Store.dataTypeFor maps model types correctly" { + try std.testing.expectEqual(DataType.candles_daily, Store.dataTypeFor(Candle)); + try std.testing.expectEqual(DataType.dividends, Store.dataTypeFor(Dividend)); + try std.testing.expectEqual(DataType.splits, Store.dataTypeFor(Split)); + try std.testing.expectEqual(DataType.earnings, Store.dataTypeFor(EarningsEvent)); + try std.testing.expectEqual(DataType.options, Store.dataTypeFor(OptionsChain)); + try std.testing.expectEqual(DataType.etf_profile, Store.dataTypeFor(EtfProfile)); +} + +test "Store.DataFor returns correct types" { + // EtfProfile returns single struct, others return slices + try std.testing.expect(@TypeOf(Store.DataFor(EtfProfile)) == type); + try std.testing.expect(Store.DataFor(EtfProfile) == EtfProfile); + try std.testing.expect(Store.DataFor(Candle) == []Candle); + try std.testing.expect(Store.DataFor(Dividend) == []Dividend); + try std.testing.expect(Store.DataFor(Split) == []Split); +} + +test "Store.Freshness enum values" { + // Ensure enum has expected values + try std.testing.expect(Store.Freshness.fresh_only != Store.Freshness.any); +} + +test "CandleProvider.fromString parses provider names" { + try std.testing.expectEqual(Store.CandleProvider.yahoo, Store.CandleProvider.fromString("yahoo")); + try std.testing.expectEqual(Store.CandleProvider.tiingo, Store.CandleProvider.fromString("tiingo")); + try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("twelvedata")); + // Unknown defaults to twelvedata + try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("unknown")); + try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("")); +} + +test "Store init creates valid store" { + const allocator = std.testing.allocator; + const store = Store.init(allocator, "/tmp/zfin-test"); + try std.testing.expectEqualStrings("/tmp/zfin-test", store.cache_dir); +} + +test "CandleMeta default provider is tiingo" { + const meta = Store.CandleMeta{ + .last_close = 100.0, + .last_date = Date.fromYmd(2024, 1, 1), + }; + try std.testing.expectEqual(Store.CandleProvider.tiingo, meta.provider); +} diff --git a/src/service.zig b/src/service.zig index 356b150..b15c695 100644 --- a/src/service.zig +++ b/src/service.zig @@ -98,10 +98,40 @@ pub const DataService = struct { tg: ?Tiingo = null, pub fn init(allocator: std.mem.Allocator, config: Config) DataService { - return .{ + const self = DataService{ .allocator = allocator, .config = config, }; + self.logMissingKeys(); + return self; + } + + /// Log warnings for missing API keys so users know which features are unavailable. + fn logMissingKeys(self: DataService) void { + // Primary candle provider + if (self.config.tiingo_key == null) { + log.warn("TIINGO_API_KEY not set — candle data will fall back to TwelveData/Yahoo", .{}); + } + // Dividend/split data + if (self.config.polygon_key == null) { + log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{}); + } + // Earnings data + if (self.config.finnhub_key == null) { + log.warn("FINNHUB_API_KEY not set — earnings data unavailable", .{}); + } + // ETF profiles + if (self.config.alphavantage_key == null) { + log.warn("ALPHAVANTAGE_API_KEY not set — ETF profiles unavailable", .{}); + } + // Candle fallback + if (self.config.twelvedata_key == null and self.config.tiingo_key == null) { + log.warn("TWELVEDATA_API_KEY not set — no candle fallback if Yahoo fails", .{}); + } + // CUSIP lookups + if (self.config.openfigi_key == null) { + log.info("OPENFIGI_API_KEY not set — CUSIP lookups will use anonymous rate limits", .{}); + } } pub fn deinit(self: *DataService) void { @@ -1252,3 +1282,139 @@ pub const DataService = struct { return symbol.len == 5 and symbol[4] == 'X'; } }; + +// ── Tests ───────────────────────────────────────────────────────── + +test "isMutualFund identifies mutual funds" { + // Standard mutual fund tickers (5 letters ending in X) + try std.testing.expect(DataService.isMutualFund("FDSCX")); + try std.testing.expect(DataService.isMutualFund("VSTCX")); + try std.testing.expect(DataService.isMutualFund("FAGIX")); + try std.testing.expect(DataService.isMutualFund("VFINX")); + + // Not mutual funds + try std.testing.expect(!DataService.isMutualFund("AAPL")); + try std.testing.expect(!DataService.isMutualFund("VTI")); + try std.testing.expect(!DataService.isMutualFund("SPY")); + try std.testing.expect(!DataService.isMutualFund("GOOGL")); + try std.testing.expect(!DataService.isMutualFund("")); // empty + try std.testing.expect(!DataService.isMutualFund("X")); // too short + try std.testing.expect(!DataService.isMutualFund("FDSCA")); // 5 letters but not ending in X + try std.testing.expect(!DataService.isMutualFund("FDSCXA")); // 6 letters ending in A +} + +test "DataService init/deinit lifecycle" { + const allocator = std.testing.allocator; + const config = Config{ + .cache_dir = "/tmp/zfin-test-cache", + }; + var svc = DataService.init(allocator, config); + defer svc.deinit(); + + // Should be able to access config + try std.testing.expectEqualStrings("/tmp/zfin-test-cache", svc.config.cache_dir); + // Providers should be null (lazy init) + try std.testing.expect(svc.td == null); + try std.testing.expect(svc.pg == null); + try std.testing.expect(svc.fh == null); + try std.testing.expect(svc.yh == null); + try std.testing.expect(svc.tg == null); +} + +test "DataService store helper creates valid store" { + const allocator = std.testing.allocator; + const config = Config{ + .cache_dir = "/tmp/zfin-test-cache", + }; + var svc = DataService.init(allocator, config); + defer svc.deinit(); + + const s = svc.store(); + try std.testing.expectEqualStrings("/tmp/zfin-test-cache", s.cache_dir); +} + +test "DataService getProvider returns NoApiKey without key" { + const allocator = std.testing.allocator; + const config = Config{ + .cache_dir = "/tmp/zfin-test-cache", + // No API keys set + }; + var svc = DataService.init(allocator, config); + defer svc.deinit(); + + // TwelveData requires API key + const td_result = svc.getProvider(TwelveData); + try std.testing.expectError(DataError.NoApiKey, td_result); + + // Polygon requires API key + const pg_result = svc.getProvider(Polygon); + try std.testing.expectError(DataError.NoApiKey, pg_result); + + // Yahoo doesn't require API key + const yh_result = svc.getProvider(Yahoo); + try std.testing.expect(yh_result != error.NoApiKey); +} + +test "DataService getProvider initializes provider with key" { + const allocator = std.testing.allocator; + const config = Config{ + .cache_dir = "/tmp/zfin-test-cache", + .tiingo_key = "test-tiingo-key", + }; + var svc = DataService.init(allocator, config); + defer svc.deinit(); + + // First call initializes + const tg1 = try svc.getProvider(Tiingo); + try std.testing.expect(svc.tg != null); + + // Second call returns same instance + const tg2 = try svc.getProvider(Tiingo); + try std.testing.expect(tg1 == tg2); +} + +test "DataService PriceLoadResult default values" { + const result = DataService.PriceLoadResult{ + .cached_count = 0, + .fetched_count = 0, + .fail_count = 0, + .stale_count = 0, + .latest_date = null, + }; + try std.testing.expectEqual(@as(usize, 0), result.cached_count); + try std.testing.expect(result.latest_date == null); +} + +test "DataService LoadAllResult default values" { + const allocator = std.testing.allocator; + var result = DataService.LoadAllResult{ + .prices = std.StringHashMap(f64).init(allocator), + .cached_count = 0, + .server_synced_count = 0, + .provider_fetched_count = 0, + .stale_count = 0, + .failed_count = 0, + .latest_date = null, + }; + defer result.deinit(); + + try std.testing.expectEqual(@as(usize, 0), result.prices.count()); +} + +test "FetchResult type construction" { + // Verify FetchResult works for different types + const candle_result = FetchResult(Candle){ + .data = &.{}, + .source = .cached, + .timestamp = 0, + }; + try std.testing.expect(candle_result.source == .cached); + + const div_result = FetchResult(Dividend){ + .data = &.{}, + .source = .fetched, + .timestamp = 12345, + }; + try std.testing.expect(div_result.source == .fetched); + try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp); +}