create a set of fetch options and wire it into CLI for flags to work properly

This commit is contained in:
Emil Lerch 2026-05-21 12:17:48 -07:00
parent 7deb49254a
commit c300729887
Signed by: lobo
GPG key ID: A7B62D657EF764F8
16 changed files with 447 additions and 70 deletions

View file

@ -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(),
);

View file

@ -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 |_| {}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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);
}

View file

@ -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 {};

View file

@ -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)";

View file

@ -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"),

View file

@ -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;
}

View file

@ -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 &.{},