diff --git a/src/commands/common.zig b/src/commands/common.zig index 30a940b..3f56381 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -126,7 +126,7 @@ pub const stderrPrint = stderr.print; pub const stderrProgress = stderr.progress; pub const stderrRateLimitWait = stderr.rateLimitWait; -/// Progress callback for loadPrices that prints to stderr. +/// Progress callback for loadAllPrices that prints to stderr. /// Shared between the CLI portfolio command and TUI pre-fetch. pub const LoadProgress = struct { io: std.Io, @@ -134,7 +134,7 @@ pub const LoadProgress = struct { color: bool, /// Offset added to index for display (e.g. stock count when loading watch symbols). index_offset: usize, - /// Grand total across all loadPrices calls (stocks + watch). + /// Grand total across all loadAllPrices calls (stocks + watch). grand_total: usize, fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { @@ -273,7 +273,7 @@ pub fn loadPortfolioPrices( }; // Map RefreshPolicy → LoadAllConfig: - // .force → invalidate cache, fetch fresh. + // .force → ignore TTL; incremental candle top-up (no wipe). // .auto → respect TTL, fetch on stale. // .never → offline mode: never touch the network. Stale cache // entries are returned; cache misses fail the symbol. diff --git a/src/commands/framework.zig b/src/commands/framework.zig index addcabb..3d613b9 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -180,10 +180,11 @@ pub fn isUserError(comptime UserErrors: type, err: anyerror) bool { /// stale entries trigger a provider refetch (or server sync if /// `ZFIN_SERVER` is configured). The right behavior for almost /// every invocation; the default. -/// - `force`: invalidate cache before reading. Re-fetches every -/// symbol's data from providers regardless of TTL freshness. -/// Useful when you suspect cached data is wrong or after rotating -/// providers. +/// - `force`: ignore cache TTL and re-validate against providers. +/// Candle history is topped up incrementally (new bars appended, +/// existing history kept), not wiped. To force a full re-download +/// from scratch (e.g. suspected-bad data or a retroactive split +/// adjustment), use `cache clear`. /// - `never`: serve whatever's in cache regardless of TTL. No /// provider calls. Useful for offline operation, debugging, and /// reproducible historical analysis. diff --git a/src/service.zig b/src/service.zig index 78a08f3..e7f6f49 100644 --- a/src/service.zig +++ b/src/service.zig @@ -1934,20 +1934,6 @@ pub const DataService = struct { // ── Portfolio price loading ────────────────────────────────── - /// Result of loading prices for a set of symbols. - pub const PriceLoadResult = struct { - /// Number of symbols whose price came from fresh cache. - cached_count: usize, - /// Number of symbols successfully fetched from API. - fetched_count: usize, - /// Number of symbols where API fetch failed. - fail_count: usize, - /// Number of failed symbols that fell back to stale cache. - stale_count: usize, - /// Latest candle date seen across all symbols. - latest_date: ?Date, - }; - /// Status emitted for each symbol during price loading. pub const SymbolStatus = enum { /// Price resolved from fresh cache. @@ -1973,78 +1959,6 @@ pub const DataService = struct { } }; - /// Load current prices for a list of symbols into `prices`. - /// - /// For each symbol the resolution order is: - /// 1. Fresh cache -> use cached last close (fast, no deserialization) - /// 2. API fetch -> use latest candle close - /// 3. Stale cache -> use last close from expired cache entry - /// - /// If `force_refresh` is true, cache is invalidated before checking freshness. - /// If `progress` is provided, it is called for each symbol with the outcome. - pub fn loadPrices( - self: *DataService, - symbols: []const []const u8, - prices: *std.StringHashMap(f64), - force_refresh: bool, - progress: ?ProgressCallback, - ) PriceLoadResult { - var result = PriceLoadResult{ - .cached_count = 0, - .fetched_count = 0, - .fail_count = 0, - .stale_count = 0, - .latest_date = null, - }; - const total = symbols.len; - - for (symbols, 0..) |sym, i| { - if (force_refresh) { - self.invalidate(sym, .candles_daily); - } - - // 1. Fresh cache — fast path (no full deserialization) - if (!force_refresh and self.isCandleCacheFresh(sym)) { - if (self.getCachedLastClose(sym)) |close| { - prices.put(sym, close) catch |err| log.warn("loadPrices cache-hit put({s}): {t}", .{ sym, err }); - } - result.cached_count += 1; - if (progress) |p| p.emit(i, total, sym, .cached); - continue; - } - - // About to fetch — notify caller (so it can show rate-limit waits etc.) - if (progress) |p| p.emit(i, total, sym, .fetching); - - // 2. Try API fetch - if (self.getCandles(sym, .{ .force_refresh = force_refresh })) |candle_result| { - defer self.allocator.free(candle_result.data); - if (candle_result.data.len > 0) { - const last = candle_result.data[candle_result.data.len - 1]; - prices.put(sym, last.close) catch |err| log.warn("loadPrices candle-close put({s}): {t}", .{ sym, err }); - if (result.latest_date == null or last.date.days > result.latest_date.?.days) { - result.latest_date = last.date; - } - } - result.fetched_count += 1; - if (progress) |p| p.emit(i, total, sym, .fetched); - continue; - } else |_| {} - - // 3. Fetch failed — fall back to stale cache - result.fail_count += 1; - if (self.getCachedLastClose(sym)) |close| { - prices.put(sym, close) catch |err| log.warn("loadPrices stale-fallback put({s}): {t}", .{ sym, err }); - result.stale_count += 1; - if (progress) |p| p.emit(i, total, sym, .failed_used_stale); - } else { - if (progress) |p| p.emit(i, total, sym, .failed); - } - } - - return result; - } - // ── Consolidated Price Loading (Parallel Server + Sequential Provider) ── /// Configuration for loadAllPrices. @@ -2173,12 +2087,13 @@ pub const DataService = struct { } for (watch_syms) |sym| all_symbols.append(self.allocator, sym) catch |err| log.warn("loadAllPrices append watch sym({s}): {t}", .{ sym, err }); - // Invalidate cache if force refresh - if (config.force_refresh) { - for (all_symbols.items) |sym| { - self.invalidate(sym, .candles_daily); - } - } + // force_refresh does NOT wipe the candle cache. It flows + // through to getCandles (via config.fetchOptions()), which + // ignores the TTL and does an incremental top-up — see the + // `--refresh-data=force` contract. The Phase-1 fast path below + // is skipped on force_refresh so every symbol is re-validated + // against the provider. A full wipe + re-download from scratch + // is reserved for `cache clear`. // Phase 1: Check local cache (fast path) var needs_fetch: std.ArrayList([]const u8) = .empty; @@ -3122,18 +3037,6 @@ test "DataService getProvider initializes provider with key" { 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{ @@ -3342,6 +3245,56 @@ test "loadAllPrices offline mode skips network and returns cached" { try std.testing.expectEqual(@as(usize, 1), result.failed_count); } +test "loadAllPrices force_refresh tops up without wiping the candle cache" { + // Regression: force_refresh must mean "ignore TTL + incremental + // top-up", NOT "delete the cache and re-download from scratch". + // The old behavior invalidated (deleted) candles_daily before the + // fetch, which forced a full network re-download. With the cache + // already covering through today, force_refresh must serve from + // the surviving cache and touch no network. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + const config = Config{ .cache_dir = dir_path }; + var svc = DataService.init(io, allocator, config); + defer svc.deinit(); + + var store = svc.store(); + // Dated far in the future so getCandles' "last cached date is + // today-or-later" branch fires deterministically regardless of the + // test clock — an incremental fetch would have nothing to pull and + // never reaches the network. + var candles = [_]Candle{ + .{ .date = Date.fromYmd(2099, 12, 31), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 }, + }; + store.cacheCandles("HELD", candles[0..], .tiingo, 0); + + // Any provider/network attempt now panics. If force_refresh wiped + // the cache (old behavior), getCandles would fall through to a full + // re-fetch and trip this. + svc.panic_on_network_attempt = true; + + const symbols = [_][]const u8{"HELD"}; + var result = svc.loadAllPrices( + symbols[0..], + &.{}, + .{ .force_refresh = true }, + null, + null, + ); + defer result.prices.deinit(); + + // Served from the (un-wiped) cache. + try std.testing.expect(result.prices.contains("HELD")); + try std.testing.expectEqual(@as(f64, 104), result.prices.get("HELD").?); + // The candle cache survived the force-refresh. + try std.testing.expect(svc.getCachedLastClose("HELD") != null); +} + test "getClassification: skip_network with no cache returns FetchFailed" { const allocator = std.testing.allocator; const io = std.testing.io;