logging and tests
This commit is contained in:
parent
7144f60d10
commit
5c19fc894b
2 changed files with 310 additions and 15 deletions
157
src/cache/store.zig
vendored
157
src/cache/store.zig
vendored
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
168
src/service.zig
168
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue