From ab7669336765757d7acfa88f6886b32be9aeaeef Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 11 Mar 2026 13:01:37 -0700 Subject: [PATCH] implement a server cache sync --- src/cache/store.zig | 4 ++- src/config.zig | 3 ++ src/service.zig | 71 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/cache/store.zig b/src/cache/store.zig index a50f53c..ae07e9b 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -374,7 +374,9 @@ pub const Store = struct { }; } - fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { + /// Write raw bytes to a cache file. Used by server sync to write + /// pre-serialized SRF data directly to the cache. + pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { try self.ensureSymbolDir(symbol); const path = try self.symbolPath(symbol, data_type.fileName()); defer self.allocator.free(path); diff --git a/src/config.zig b/src/config.zig index c2f4388..bc7053b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -8,6 +8,8 @@ pub const Config = struct { finnhub_key: ?[]const u8 = null, alphavantage_key: ?[]const u8 = null, openfigi_key: ?[]const u8 = null, + /// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org") + server_url: ?[]const u8 = null, cache_dir: []const u8, allocator: ?std.mem.Allocator = null, /// Raw .env file contents (keys/values in env_map point into this). @@ -32,6 +34,7 @@ pub const Config = struct { self.finnhub_key = self.resolve("FINNHUB_API_KEY"); self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY"); self.openfigi_key = self.resolve("OPENFIGI_API_KEY"); + self.server_url = self.resolve("ZFIN_SERVER"); const env_cache = self.resolve("ZFIN_CACHE_DIR"); self.cache_dir = env_cache orelse blk: { diff --git a/src/service.zig b/src/service.zig index 3356e18..9b1f4e1 100644 --- a/src/service.zig +++ b/src/service.zig @@ -28,6 +28,7 @@ const alphavantage = @import("providers/alphavantage.zig"); const OpenFigi = @import("providers/openfigi.zig"); const fmt = @import("format.zig"); const performance = @import("analytics/performance.zig"); +const http = @import("net/http.zig"); pub const DataError = error{ NoApiKey, @@ -164,6 +165,12 @@ pub const DataService = struct { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + // Try server sync before hitting providers + if (self.syncFromServer(symbol, data_type)) { + 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 @@ -232,6 +239,15 @@ pub const DataService = struct { return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; } + // Stale — try server sync before incremental fetch + if (self.syncCandlesFromServer(symbol)) { + if (s.isCandleMetaFresh(symbol)) { + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + } + // Server data also stale — fall through to incremental fetch + } + // Stale — try incremental update using last_date from meta const fetch_from = m.last_date.addDays(1); @@ -283,6 +299,15 @@ pub const DataService = struct { } } + // No usable cache — try server sync first + if (self.syncCandlesFromServer(symbol)) { + if (s.isCandleMetaFresh(symbol)) { + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; + } + // Server data also stale — fall through to full fetch + } + // No usable cache — full fetch (~10 years, plus buffer for leap years) var td = try self.getProvider(TwelveData); const from = today.addDays(-3700); @@ -347,6 +372,12 @@ pub const DataService = struct { self.allocator.free(cached.data); } + // Try server sync before hitting Finnhub + if (self.syncFromServer(symbol, .earnings)) { + if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; + } + var fh = try self.getProvider(Finnhub); const from = today.subtractYears(5); const to = today.addDays(365); @@ -721,6 +752,46 @@ pub const DataService = struct { } } + // ── Server sync ────────────────────────────────────────────── + + /// Try to sync a cache file from the configured zfin-server. + /// Returns true if the file was successfully synced, false on any error. + /// Silently returns false if no server is configured. + fn syncFromServer(self: *DataService, symbol: []const u8, data_type: cache.DataType) bool { + const server_url = self.config.server_url orelse return false; + const endpoint = switch (data_type) { + .candles_daily => "/candles", + .candles_meta => "/candles_meta", + .dividends => "/dividends", + .earnings => "/earnings", + .options => "/options", + .splits => return false, // not served + .etf_profile => return false, // not served + .meta => return false, + }; + + const full_url = std.fmt.allocPrint(self.allocator, "{s}/{s}{s}", .{ server_url, symbol, endpoint }) catch return false; + defer self.allocator.free(full_url); + + var client = http.Client.init(self.allocator); + defer client.deinit(); + + var response = client.get(full_url) catch return false; + defer response.deinit(); + + // Write to local cache + var s = self.store(); + s.writeRaw(symbol, data_type, response.body) catch return false; + return true; + } + + /// Sync candle data (both daily and meta) from the server. + fn syncCandlesFromServer(self: *DataService, symbol: []const u8) bool { + const daily = self.syncFromServer(symbol, .candles_daily); + const meta = self.syncFromServer(symbol, .candles_meta); + return daily and meta; + } + /// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX). /// These don't have quarterly earnings on Finnhub. fn isMutualFund(symbol: []const u8) bool {