diff --git a/src/commands/common.zig b/src/commands/common.zig index 8f1fbd8..02c3cc5 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -257,6 +257,21 @@ pub const AggregateProgress = struct { } }; +/// Map a `RefreshPolicy` to per-call `FetchOptions`. Single-symbol +/// commands use this to thread `--refresh-data` through to +/// `getCandles`/`getDividends`/etc. The mapping is: +/// +/// `.auto` → `.{}` (default; respect TTL) +/// `.force` → `.{ .force_refresh = true }` (ignore TTL, fetch fresh) +/// `.never` → `.{ .skip_network = true }` (offline mode) +pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions { + return switch (policy) { + .auto => .{}, + .force => .{ .force_refresh = true }, + .never => .{ .skip_network = true }, + }; +} + /// Unified price loading for both CLI and TUI. /// Handles parallel server sync when ZFIN_SERVER is configured, /// with sequential provider fallback for failures. @@ -277,18 +292,19 @@ pub fn loadPortfolioPrices( .grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len, }; - // .force → invalidate cache before reading; the underlying - // loader's `force_refresh` does exactly that. - // .never → today the underlying loader has no "skip TTL - // entirely" knob, so we approximate by passing - // `force_refresh = false` (TTL-respecting). A true - // skip-network mode is a follow-up if/when needed; the - // common case for `--refresh-data=never` is "the cache is - // fresh and I'm offline," which works fine via TTL today. + // Map RefreshPolicy → LoadAllConfig: + // .force → invalidate cache, fetch fresh. + // .auto → respect TTL, fetch on stale. + // .never → offline mode: never touch the network. Stale cache + // entries are returned; cache misses fail the symbol. const result = svc.loadAllPrices( portfolio_syms, watch_syms, - .{ .force_refresh = refresh == .force, .color = color }, + .{ + .force_refresh = refresh == .force, + .skip_network = refresh == .never, + .color = color, + }, aggregate.callback(), symbol_progress.callback(), ); diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 0bccecc..a716d5c 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -42,7 +42,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getDividends(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; @@ -58,7 +59,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Fetch current price for yield calculation via DataService var current_price: ?f64 = null; - if (svc.getQuote(parsed.symbol)) |q| { + if (svc.getQuote(parsed.symbol, opts)) |q| { current_price = q.close; } else |_| {} diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index 5a3e14e..809c388 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -48,7 +48,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getEarnings(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); return; diff --git a/src/commands/etf.zig b/src/commands/etf.zig index 1cfaabe..9711cf4 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -43,7 +43,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getEtfProfile(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); return; diff --git a/src/commands/history.zig b/src/commands/history.zig index 373fa0d..2f60f62 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -187,8 +187,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr /// the parsed args. pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; + const fetch_opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); switch (parsed) { - .symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out), + .symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts), .portfolio => |opts| { const pf = ctx.resolvePortfolioPath(); defer pf.deinit(ctx.allocator); @@ -206,8 +207,9 @@ fn runSymbol( as_of: zfin.Date, color: bool, out: *std.Io.Writer, + opts: zfin.FetchOptions, ) !void { - const result = svc.getCandles(symbol) catch |err| switch (err) { + const result = svc.getCandles(symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(io, "Error: No API key configured for candle data.\n"); return; diff --git a/src/commands/options.zig b/src/commands/options.zig index dce8eb2..5ff9173 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -67,7 +67,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getOptions(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.FetchFailed => { try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n"); return; diff --git a/src/commands/perf.zig b/src/commands/perf.zig index 5908535..a7d8b07 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -50,7 +50,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getTrailingReturns(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); return; diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 78d4575..cfc9ae4 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -96,8 +96,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); // Fetch candle data for chart and history - const candle_result = svc.getCandles(parsed.symbol) catch |err| switch (err) { + const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n"); return; @@ -137,7 +138,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Fetch real-time quote via DataService var quote: ?QuoteData = null; - if (svc.getQuote(parsed.symbol)) |q| { + if (svc.getQuote(parsed.symbol, opts)) |q| { quote = .{ .price = q.close, .open = q.open, diff --git a/src/commands/splits.zig b/src/commands/splits.zig index 9a9f9ca..0b2cddf 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -42,7 +42,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; - const result = svc.getSplits(parsed.symbol) catch |err| switch (err) { + const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); + const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; diff --git a/src/root.zig b/src/root.zig index 23004c5..fb24615 100644 --- a/src/root.zig +++ b/src/root.zig @@ -97,6 +97,10 @@ pub const DataService = @import("service.zig").DataService; /// Errors returned by DataService (NoApiKey, RateLimited, etc.). pub const DataError = @import("service.zig").DataError; +/// Per-call options controlling cache vs network behavior. +/// Drives the `--refresh-data` global flag. +pub const FetchOptions = @import("service.zig").FetchOptions; + /// Company overview data (sector, industry, country, market cap) from Alpha Vantage. pub const CompanyOverview = @import("service.zig").CompanyOverview; diff --git a/src/service.zig b/src/service.zig index 8333aee..86a1f5f 100644 --- a/src/service.zig +++ b/src/service.zig @@ -62,6 +62,38 @@ pub const DataError = error{ AuthError, }; +/// Per-call options controlling cache vs network behavior. Drives +/// the `--refresh-data` global flag's three modes: +/// +/// - `--refresh-data=auto` → `.{}` (default; respect TTL, fetch on stale/miss). +/// - `--refresh-data=never` → `.{ .skip_network = true }` (offline mode; +/// return cached data even if stale, treat cache miss as unavailable). +/// - `--refresh-data=force` → `.{ .force_refresh = true }` (ignore cache TTL, +/// fetch fresh from provider). +/// +/// `skip_network` and `force_refresh` represent contradictory intents. +/// The CLI flag cannot produce the combination — `RefreshPolicy` is a +/// 3-variant enum, so the user can never set both. But because the +/// underlying shape is two independent booleans, an internal caller +/// constructing `FetchOptions` directly *could* produce the +/// combination. When both are true, **`skip_network` wins**: +/// +/// - The call returns cached data (fresh or stale, whatever's there). +/// - `force_refresh` has no effect — no network is touched. +/// +/// This is the safe default: when in doubt, don't reach the network. +/// Internal callers that genuinely want fresh data should set +/// `force_refresh = true, skip_network = false`. +pub const FetchOptions = struct { + /// Skip provider fetches and server sync. Returns cached data + /// (even if stale) or null/empty on cache miss. Wins over + /// `force_refresh` when both are set. + skip_network: bool = false, + /// Force a fresh fetch ignoring cache TTL. No-op when + /// `skip_network` is also set. + force_refresh: bool = false, +}; + /// Decide whether a provider failure is permanent enough to merit a /// negative-cache entry. Negative entries suppress retries until the /// next manual `--refresh-data=force` / `cache clear`, so writing one is only @@ -202,6 +234,13 @@ pub const DataService = struct { yh: ?Yahoo = null, tg: ?Tiingo = null, + /// Test-only guard: when true, any code path that would touch + /// the network panics with a clear message. Used by offline-mode + /// tests to verify that `FetchOptions.skip_network = true` + /// genuinely doesn't reach the network. Default false; never + /// set in production. + panic_on_network_attempt: bool = false, + pub fn init(io: std.Io, allocator: std.mem.Allocator, config: Config) DataService { const self = DataService{ .allocator = allocator, @@ -299,22 +338,42 @@ pub const DataService = struct { /// 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. + /// + /// `opts.skip_network = true` → returns cached data even if stale, + /// returns FetchFailed on cache miss without touching the network. + /// `opts.force_refresh = true` → treats cache as stale and fetches. fn fetchCached( self: *DataService, comptime T: type, symbol: []const u8, comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void, + opts: FetchOptions, ) DataError!FetchResult(T) { var s = self.store(); const data_type = comptime cache.Store.dataTypeFor(T); - if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { - log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + // Force-refresh skips the fresh-cache early return; falls + // through to provider fetch. Skip-network does the opposite: + // returns cached even if stale, never touches the network. + if (!opts.force_refresh) { + if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { + log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } } - // Try server sync before hitting providers - if (self.syncFromServer(symbol, data_type)) { + if (opts.skip_network) { + // Offline mode: return whatever's cached, even if stale. + // Cache miss is FetchFailed (not a network error). + if (s.read(T, symbol, postProcess, .any)) |cached| { + log.info("{s}: {s} stale-cached returned (skip_network)", .{ symbol, @tagName(data_type) }); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + return DataError.FetchFailed; + } + + // Try server sync before hitting providers (skipped on force_refresh). + if (!opts.force_refresh and self.syncFromServer(symbol, data_type)) { if (s.read(T, symbol, postProcess, .fresh_only)) |cached| { log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; @@ -323,6 +382,7 @@ pub const DataService = struct { } log.debug("{s}: fetching {s} from provider", .{ symbol, @tagName(data_type) }); + self.assertNetworkAllowed("fetchCached fetchFromProvider"); const fetched = self.fetchFromProvider(T, symbol) catch |err| { if (err == error.RateLimited) { // Wait and retry once @@ -556,12 +616,27 @@ pub const DataService = struct { err == error.RequestFailed; } + /// Centralized "are we about to touch the network?" gate. Tests + /// set `panic_on_network_attempt` to assert that offline-mode + /// paths never reach this site. Production callers always pass. + /// Inline so the panic body is only generated when the field is + /// actually checked (no overhead on the false branch). + inline fn assertNetworkAllowed(self: *DataService, context: []const u8) void { + if (self.panic_on_network_attempt) { + std.debug.panic("network attempted in offline-mode test: {s}", .{context}); + } + } + /// Fetch daily candles for a symbol (10+ years for trailing returns). /// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing. /// Uses incremental updates: when the cache is stale, only fetches /// candles newer than the last cached date rather than re-fetching /// the entire history. - pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) { + /// + /// `opts.skip_network = true` → returns cached data even if stale, + /// returns FetchFailed on cache miss without touching the network. + /// `opts.force_refresh = true` → treats cache as stale and fetches. + pub fn getCandles(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Candle) { var s = self.store(); const today = fmt.todayDate(self.io); @@ -570,18 +645,37 @@ pub const DataService = struct { if (meta_result) |mr| { const m = mr.meta; + // Offline mode: return cached data without touching the + // network. Cache miss / TwelveData-only cache is treated + // as unavailable. + if (opts.skip_network) { + if (m.provider == .twelvedata) { + log.debug("{s}: skip_network and only TwelveData cached — treating as unavailable", .{symbol}); + return DataError.FetchFailed; + } + if (s.read(Candle, symbol, null, .any)) |r| { + if (!s.isCandleMetaFresh(symbol)) { + log.info("{s}: candles stale-cached returned (skip_network)", .{symbol}); + } + return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; + } + return DataError.FetchFailed; + } + // If cached data is from TwelveData (deprecated for candles due to // unreliable adj_close), skip cache and fall through to full re-fetch. if (m.provider == .twelvedata) { log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol}); - } else if (s.isCandleMetaFresh(symbol)) { + } else if (!opts.force_refresh and s.isCandleMetaFresh(symbol)) { // Fresh — deserialize candles and return log.debug("{s}: candles fresh in local cache", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; } else { - // Stale — try server sync before incremental fetch - if (self.syncCandlesFromServer(symbol)) { + // Stale — try server sync before incremental fetch. + // (Force-refresh skips server sync too: the user explicitly + // asked for fresh provider data.) + if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| @@ -600,6 +694,7 @@ pub const DataService = struct { return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; } else { // Incremental fetch from day after last cached candle + self.assertNetworkAllowed("getCandles incremental fetchCandlesFromProviders"); const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| { if (err == DataError.TransientError) { // Increment fail_count for this symbol @@ -641,8 +736,14 @@ pub const DataService = struct { } } - // No usable cache — try server sync first - if (self.syncCandlesFromServer(symbol)) { + // Offline mode + no usable cache — give up. + if (opts.skip_network) { + log.debug("{s}: skip_network and no cached candles — unavailable", .{symbol}); + return DataError.FetchFailed; + } + + // No usable cache — try server sync first (skipped on force_refresh). + if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol}); if (s.read(Candle, symbol, null, .any)) |r| @@ -660,6 +761,7 @@ pub const DataService = struct { // history, plus a buffer for older corporate actions like // SPYM's 2017-10-16 split. log.debug("{s}: fetching full candle history from provider", .{symbol}); + self.assertNetworkAllowed("getCandles full populateAllFromTiingo"); const triple = self.populateAllFromTiingo(symbol) catch |err| { if (err == error.RateLimited or err == error.ServerError or err == error.RequestFailed) { @@ -687,25 +789,29 @@ pub const DataService = struct { } /// Fetch dividend history for a symbol. - pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) { - return self.fetchCached(Dividend, symbol, dividendPostProcess); + pub fn getDividends(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Dividend) { + return self.fetchCached(Dividend, symbol, dividendPostProcess, opts); } /// Fetch split history for a symbol. - pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) { - return self.fetchCached(Split, symbol, null); + pub fn getSplits(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Split) { + return self.fetchCached(Split, symbol, null, opts); } /// Fetch options chain for a symbol (all expirations, no API key needed). - pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) { - return self.fetchCached(OptionsChain, symbol, null); + pub fn getOptions(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(OptionsChain) { + return self.fetchCached(OptionsChain, symbol, null, opts); } /// Fetch earnings history for a symbol. /// Checks cache first; fetches from FMP if stale/missing. /// 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). - pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) { + /// + /// `opts.skip_network = true` → returns cached data even if stale, + /// returns FetchFailed on cache miss without touching the network. + /// `opts.force_refresh = true` → treats cache as stale and fetches. + pub fn getEarnings(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EarningsEvent) { // Mutual funds (5-letter tickers ending in X) don't have quarterly earnings. if (isMutualFund(symbol)) { return .{ .data = &.{}, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; @@ -714,23 +820,35 @@ pub const DataService = struct { var s = self.store(); const today = fmt.todayDate(self.io); - if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { - // Check if any past/today earnings event is still missing actual results. - // If so, the announcement likely just happened — force a refresh. - const needs_refresh = for (cached.data) |ev| { - if (ev.actual == null and !today.lessThan(ev.date)) break true; - } else false; + if (!opts.force_refresh) { + if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { + // Check if any past/today earnings event is still missing actual results. + // If so, the announcement likely just happened — force a refresh. + // (Suppressed when opts.skip_network — offline mode never refetches.) + const needs_refresh = if (opts.skip_network) false else for (cached.data) |ev| { + if (ev.actual == null and !today.lessThan(ev.date)) break true; + } else false; - if (!needs_refresh) { - log.debug("{s}: earnings fresh in local cache", .{symbol}); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + if (!needs_refresh) { + log.debug("{s}: earnings fresh in local cache", .{symbol}); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + // Stale: free cached events and re-fetch below + self.allocator.free(cached.data); } - // Stale: free cached events and re-fetch below - self.allocator.free(cached.data); } - // Try server sync before hitting FMP - if (self.syncFromServer(symbol, .earnings)) { + if (opts.skip_network) { + // Offline mode: fall back to any cached entry (even stale) before giving up. + if (s.read(EarningsEvent, symbol, earningsPostProcess, .any)) |cached| { + log.info("{s}: earnings stale-cached returned (skip_network)", .{symbol}); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + return DataError.FetchFailed; + } + + // Try server sync before hitting FMP (skipped on force_refresh). + if (!opts.force_refresh and self.syncFromServer(symbol, .earnings)) { if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { log.debug("{s}: earnings synced from server and fresh", .{symbol}); return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; @@ -739,6 +857,7 @@ pub const DataService = struct { } log.debug("{s}: fetching earnings from provider", .{symbol}); + self.assertNetworkAllowed("getEarnings fmp.fetchEarnings"); var fmp = try self.getProvider(Fmp); const fetched = fmp.fetchEarnings(self.allocator, symbol) catch |err| blk: { @@ -761,12 +880,27 @@ pub const DataService = struct { /// Fetch ETF profile for a symbol. /// Checks cache first; fetches from Alpha Vantage if stale/missing. - pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) { + /// + /// `opts.skip_network = true` → returns cached data even if stale, + /// returns FetchFailed on cache miss without touching the network. + /// `opts.force_refresh = true` → treats cache as stale and fetches. + pub fn getEtfProfile(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EtfProfile) { var s = self.store(); - if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + if (!opts.force_refresh) { + if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + if (opts.skip_network) { + if (s.read(EtfProfile, symbol, null, .any)) |cached| { + log.info("{s}: etf_profile stale-cached returned (skip_network)", .{symbol}); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + return DataError.FetchFailed; + } + + self.assertNetworkAllowed("getEtfProfile av.fetchEtfProfile"); var av = try self.getProvider(AlphaVantage); const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: { if (err == error.RateLimited) { @@ -789,7 +923,19 @@ pub const DataService = struct { /// Fetch a real-time quote for a symbol. /// Yahoo Finance is primary (free, no API key, no 15-min delay). /// Falls back to TwelveData if Yahoo fails. - pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote { + /// + /// Quotes are never cached, so `opts.force_refresh` is a no-op + /// (every call goes to the provider). `opts.skip_network = true` + /// returns FetchFailed unconditionally — there's no cached price + /// to fall back to. + pub fn getQuote(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!Quote { + if (opts.skip_network) { + log.debug("{s}: skip_network — quote unavailable (never cached)", .{symbol}); + return DataError.FetchFailed; + } + + self.assertNetworkAllowed("getQuote"); + // Primary: Yahoo Finance (free, real-time) if (self.getProvider(Yahoo)) |yh| { if (yh.fetchQuote(self.allocator, symbol)) |quote| { @@ -827,7 +973,7 @@ pub const DataService = struct { /// publish). `*_total` columns include dividend reinvestment (matches Morningstar /// "Trailing Returns" / Yahoo "Performance Overview" / Koyfin "Total Return"). /// See `tmp/multi-ticker-audit.md` for the cross-validation evidence. - pub fn getTrailingReturns(self: *DataService, symbol: []const u8) DataError!struct { + pub fn getTrailingReturns(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!struct { asof_price: performance.TrailingReturns, asof_total: ?performance.TrailingReturns, me_price: performance.TrailingReturns, @@ -837,7 +983,7 @@ pub const DataService = struct { source: Source, timestamp: i64, } { - const candle_result = try self.getCandles(symbol); + const candle_result = try self.getCandles(symbol, opts); const c = candle_result.data; if (c.len == 0) return DataError.FetchFailed; @@ -850,7 +996,7 @@ pub const DataService = struct { // tickers with no splits in the window (i.e. most of them). var splits_buf: ?FetchResult(Split) = null; defer if (splits_buf) |sb| sb.deinit(); - const splits: []const Split = if (self.getSplits(symbol)) |sr| blk: { + const splits: []const Split = if (self.getSplits(symbol, opts)) |sr| blk: { splits_buf = sr; break :blk sr.data; } else |_| &.{}; @@ -875,7 +1021,7 @@ pub const DataService = struct { const asof_adj = performance.trailingReturns(c); const me_adj = performance.trailingReturnsMonthEnd(c, today); - if (self.getDividends(symbol)) |div_result| { + if (self.getDividends(symbol, opts)) |div_result| { divs = div_result.data; const asof_div = performance.trailingReturnsWithDividends(c, div_result.data); const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); @@ -1055,7 +1201,7 @@ pub const DataService = struct { if (progress) |p| p.emit(i, total, sym, .fetching); // 2. Try API fetch - if (self.getCandles(sym)) |candle_result| { + 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]; @@ -1088,9 +1234,20 @@ pub const DataService = struct { /// Configuration for loadAllPrices. pub const LoadAllConfig = struct { force_refresh: bool = false, + /// Skip provider fetches and server sync. Returns cached + /// data (even if stale) and treats cache miss as failure. + /// Drives `--refresh-data=never`. + skip_network: bool = false, color: bool = true, /// Maximum concurrent server sync requests. 0 = auto (8). max_concurrent: usize = 0, + + /// Map this config to the per-call `FetchOptions` shape. + /// Convenience for paths that need to pass through to + /// `getCandles`/`getDividends`/etc. + pub fn fetchOptions(self: LoadAllConfig) FetchOptions { + return .{ .skip_network = self.skip_network, .force_refresh = self.force_refresh }; + } }; /// Result of loadAllPrices operation. @@ -1234,6 +1391,23 @@ pub const DataService = struct { return result; } + // Offline mode: skip server sync and provider fetch entirely. + // For symbols without a fresh cache, fall back to stale cache + // before giving up. + if (config.skip_network) { + for (needs_fetch.items) |sym| { + if (self.getCachedLastClose(sym)) |close| { + result.prices.put(sym, close) catch {}; + self.updateLatestDate(&result, sym); + result.stale_count += 1; + } else { + result.failed_count += 1; + } + } + if (aggregate_progress) |p| p.emit(total_count, total_count, .complete); + return result; + } + // Phase 2: Server sync (parallel if server configured) var server_failures: std.ArrayList([]const u8) = .empty; defer server_failures.deinit(self.allocator); @@ -1267,6 +1441,7 @@ pub const DataService = struct { &result, symbol_progress, total_count - server_failures.items.len, // offset for progress display + config.fetchOptions(), ); } @@ -1386,6 +1561,7 @@ pub const DataService = struct { result: *LoadAllResult, progress: ?ProgressCallback, index_offset: usize, + opts: FetchOptions, ) void { const total = index_offset + symbols.len; @@ -1396,7 +1572,7 @@ pub const DataService = struct { if (progress) |p| p.emit(display_idx, total, sym, .fetching); // Try provider fetch - if (self.getCandles(sym)) |candle_result| { + if (self.getCandles(sym, opts)) |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]; @@ -1958,3 +2134,175 @@ test "FetchResult type construction" { try std.testing.expect(div_result.source == .fetched); try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp); } + +test "FetchOptions default is fully permissive" { + // Default-init should allow normal fetch behavior. + const opts: FetchOptions = .{}; + try std.testing.expect(!opts.skip_network); + try std.testing.expect(!opts.force_refresh); +} + +test "LoadAllConfig.fetchOptions maps fields through" { + const cfg = DataService.LoadAllConfig{ + .force_refresh = true, + .skip_network = false, + }; + const opts = cfg.fetchOptions(); + try std.testing.expect(opts.force_refresh); + try std.testing.expect(!opts.skip_network); + + const cfg2 = DataService.LoadAllConfig{ + .skip_network = true, + }; + const opts2 = cfg2.fetchOptions(); + try std.testing.expect(opts2.skip_network); + try std.testing.expect(!opts2.force_refresh); +} + +test "getCandles offline mode returns cached data without 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); + + // Construct a service with a cache pre-populated with candle data. + const config = Config{ .cache_dir = dir_path }; + var svc = DataService.init(io, allocator, config); + defer svc.deinit(); + + // Pre-populate cache via the Store API. + var store = svc.store(); + var candles = [_]Candle{ + .{ .date = Date.fromYmd(2026, 5, 19), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 }, + .{ .date = Date.fromYmd(2026, 5, 20), .open = 104, .high = 106, .low = 103, .close = 105, .adj_close = 105, .volume = 1100 }, + }; + store.cacheCandles("TEST", candles[0..], .tiingo, 0); + + // Set the test guard: any network call would panic. We expect + // the offline-mode path NOT to touch the network. + svc.panic_on_network_attempt = true; + + const result = try svc.getCandles("TEST", .{ .skip_network = true }); + defer result.deinit(); + + try std.testing.expectEqual(@as(usize, 2), result.data.len); + try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 5, 19))); + try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2026, 5, 20))); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "getCandles offline mode with no cache returns FetchFailed" { + 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(); + + // Network guard is on. With no cache and skip_network=true, + // we must return FetchFailed without panicking. + svc.panic_on_network_attempt = true; + + const err = svc.getCandles("NEVERHEARDOFIT", .{ .skip_network = true }); + try std.testing.expectError(DataError.FetchFailed, err); +} + +test "fetchCached offline mode returns stale-cached data" { + 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(); + + // Pre-populate dividend cache with a TTL in the past (stale). + var store = svc.store(); + var divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2026, 3, 15), .amount = 0.50, .type = .regular }, + }; + // Manually set TTL to 1 second (long since expired) by writing + // through writeWithSource with a tiny TTL. + store.writeWithSource(Dividend, "TEST", divs[0..], -1_000_000, "test"); + + svc.panic_on_network_attempt = true; + + // Even though the cache is stale, skip_network must return it + // rather than touching the network. + const result = try svc.getDividends("TEST", .{ .skip_network = true }); + defer result.deinit(); + + try std.testing.expectEqual(@as(usize, 1), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "getQuote offline mode returns FetchFailed (quotes never cached)" { + 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(); + + svc.panic_on_network_attempt = true; + + // Quotes have no cache to fall back to in offline mode. + const err = svc.getQuote("AAPL", .{ .skip_network = true }); + try std.testing.expectError(DataError.FetchFailed, err); +} + +test "loadAllPrices offline mode skips network and returns cached" { + 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(); + // Symbol with fresh cache. + var fresh_candles = [_]Candle{ + .{ .date = Date.fromYmd(2026, 5, 20), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 }, + }; + store.cacheCandles("FRESH", fresh_candles[0..], .tiingo, 0); + + // Symbol with no cache at all. + // (no setup needed — just passes a symbol that doesn't exist) + + svc.panic_on_network_attempt = true; + + const symbols = [_][]const u8{ "FRESH", "MISSING" }; + var result = svc.loadAllPrices( + symbols[0..], + &.{}, + .{ .skip_network = true }, + null, + null, + ); + defer result.prices.deinit(); + + // FRESH should resolve from cache. + try std.testing.expect(result.prices.contains("FRESH")); + try std.testing.expectEqual(@as(f64, 104), result.prices.get("FRESH").?); + // MISSING should not be in the prices map. + try std.testing.expect(!result.prices.contains("MISSING")); + // failed_count should reflect MISSING. + try std.testing.expectEqual(@as(usize, 1), result.failed_count); +} diff --git a/src/tui.zig b/src/tui.zig index 24bf0a2..e7e3627 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1178,7 +1178,7 @@ pub const App = struct { switch (self.active_tab) { .quote, .performance => { if (self.symbol.len > 0) { - if (self.svc.getQuote(self.symbol)) |q| { + if (self.svc.getQuote(self.symbol, .{})) |q| { self.states.quote.live = q; // wall-clock required: records the exact moment // this quote was served so the "refreshed Xs ago" @@ -1284,7 +1284,7 @@ pub const App = struct { var wp = &(self.portfolio.watchlist_prices.?); if (self.watchlist) |wl| { for (wl) |sym| { - const result = self.svc.getCandles(sym) catch continue; + const result = self.svc.getCandles(sym, .{}) catch continue; defer result.deinit(); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; @@ -1294,7 +1294,7 @@ pub const App = struct { for (pf.lots) |lot| { if (lot.security_type == .watch) { const sym = lot.priceSymbol(); - const result = self.svc.getCandles(sym) catch continue; + const result = self.svc.getCandles(sym, .{}) catch continue; defer result.deinit(); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index b211d8d..d883062 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -136,7 +136,7 @@ fn loadData(state: *State, app: *App) void { state.loaded = true; state.error_msg = null; - const result = app.svc.getEarnings(app.symbol) catch |err| { + const result = app.svc.getEarnings(app.symbol, .{}) catch |err| { switch (err) { zfin.DataError.NoApiKey => { state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)"; diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 3714eea..9c29e3d 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -288,7 +288,7 @@ fn loadData(state: *State, app: *App) void { } state.chains = null; - const result = app.svc.getOptions(app.symbol) catch |err| { + const result = app.svc.getOptions(app.symbol, .{}) catch |err| { switch (err) { zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"), else => app.setStatus("Error loading options"), diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index ecdec75..7307d74 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -120,7 +120,7 @@ fn loadData(state: *State, app: *App) void { app.symbol_data.trailing_me_price = null; app.symbol_data.trailing_me_total = null; - const result = app.svc.getTrailingReturns(app.symbol) catch |err| { + const result = app.svc.getTrailingReturns(app.symbol, .{}) catch |err| { switch (err) { zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), @@ -156,7 +156,7 @@ fn loadData(state: *State, app: *App) void { // Try to load ETF profile (non-fatal, won't show for non-ETFs) if (!app.symbol_data.etf_loaded) { app.symbol_data.etf_loaded = true; - if (app.svc.getEtfProfile(app.symbol)) |etf_result| { + if (app.svc.getEtfProfile(app.symbol, .{})) |etf_result| { if (etf_result.data.isEtf()) { app.symbol_data.etf_profile = etf_result.data; } diff --git a/src/views/projections.zig b/src/views/projections.zig index 293484e..ec80f0c 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -705,14 +705,14 @@ fn buildContextFromParts( // mode we slice to `<= as_of` — `performance.trailingReturns` // anchors on the last candle's date, so trimming the tail gives // returns "as of" that date for free. - const spy_result = svc.getCandles("SPY") catch null; + const spy_result = svc.getCandles("SPY", .{}) catch null; defer if (spy_result) |r| r.deinit(); const spy_candles = history.sliceCandlesAsOf( if (spy_result) |r| r.data else &.{}, as_of, ); - const agg_result = svc.getCandles("AGG") catch null; + const agg_result = svc.getCandles("AGG", .{}) catch null; defer if (agg_result) |r| r.deinit(); const agg_candles = history.sliceCandlesAsOf( if (agg_result) |r| r.data else &.{},