create a set of fetch options and wire it into CLI for flags to work properly
This commit is contained in:
parent
7cc220657b
commit
c410bf97b1
16 changed files with 447 additions and 70 deletions
|
|
@ -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.
|
/// Unified price loading for both CLI and TUI.
|
||||||
/// Handles parallel server sync when ZFIN_SERVER is configured,
|
/// Handles parallel server sync when ZFIN_SERVER is configured,
|
||||||
/// with sequential provider fallback for failures.
|
/// 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,
|
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
|
||||||
};
|
};
|
||||||
|
|
||||||
// .force → invalidate cache before reading; the underlying
|
// Map RefreshPolicy → LoadAllConfig:
|
||||||
// loader's `force_refresh` does exactly that.
|
// .force → invalidate cache, fetch fresh.
|
||||||
// .never → today the underlying loader has no "skip TTL
|
// .auto → respect TTL, fetch on stale.
|
||||||
// entirely" knob, so we approximate by passing
|
// .never → offline mode: never touch the network. Stale cache
|
||||||
// `force_refresh = false` (TTL-respecting). A true
|
// entries are returned; cache misses fail the symbol.
|
||||||
// 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.
|
|
||||||
const result = svc.loadAllPrices(
|
const result = svc.loadAllPrices(
|
||||||
portfolio_syms,
|
portfolio_syms,
|
||||||
watch_syms,
|
watch_syms,
|
||||||
.{ .force_refresh = refresh == .force, .color = color },
|
.{
|
||||||
|
.force_refresh = refresh == .force,
|
||||||
|
.skip_network = refresh == .never,
|
||||||
|
.color = color,
|
||||||
|
},
|
||||||
aggregate.callback(),
|
aggregate.callback(),
|
||||||
symbol_progress.callback(),
|
symbol_progress.callback(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||||
return;
|
return;
|
||||||
|
|
@ -58,7 +59,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
// Fetch current price for yield calculation via DataService
|
// Fetch current price for yield calculation via DataService
|
||||||
var current_price: ?f64 = null;
|
var current_price: ?f64 = null;
|
||||||
if (svc.getQuote(parsed.symbol)) |q| {
|
if (svc.getQuote(parsed.symbol, opts)) |q| {
|
||||||
current_price = q.close;
|
current_price = q.close;
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
|
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
|
try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -187,8 +187,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
||||||
/// the parsed args.
|
/// the parsed args.
|
||||||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
const svc = ctx.svc orelse return error.MissingDataService;
|
||||||
|
const fetch_opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||||
switch (parsed) {
|
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| {
|
.portfolio => |opts| {
|
||||||
const pf = ctx.resolvePortfolioPath();
|
const pf = ctx.resolvePortfolioPath();
|
||||||
defer pf.deinit(ctx.allocator);
|
defer pf.deinit(ctx.allocator);
|
||||||
|
|
@ -206,8 +207,9 @@ fn runSymbol(
|
||||||
as_of: zfin.Date,
|
as_of: zfin.Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
|
opts: zfin.FetchOptions,
|
||||||
) !void {
|
) !void {
|
||||||
const result = svc.getCandles(symbol) catch |err| switch (err) {
|
const result = svc.getCandles(symbol, opts) catch |err| switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
|
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
zfin.DataError.FetchFailed => {
|
||||||
try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
|
try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
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");
|
try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
const svc = ctx.svc orelse return error.MissingDataService;
|
||||||
|
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||||
// Fetch candle data for chart and history
|
// 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 => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
|
try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
|
||||||
return;
|
return;
|
||||||
|
|
@ -137,7 +138,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
// Fetch real-time quote via DataService
|
// Fetch real-time quote via DataService
|
||||||
var quote: ?QuoteData = null;
|
var quote: ?QuoteData = null;
|
||||||
if (svc.getQuote(parsed.symbol)) |q| {
|
if (svc.getQuote(parsed.symbol, opts)) |q| {
|
||||||
quote = .{
|
quote = .{
|
||||||
.price = q.close,
|
.price = q.close,
|
||||||
.open = q.open,
|
.open = q.open,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const svc = ctx.svc orelse return error.MissingDataService;
|
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 => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ pub const DataService = @import("service.zig").DataService;
|
||||||
/// Errors returned by DataService (NoApiKey, RateLimited, etc.).
|
/// Errors returned by DataService (NoApiKey, RateLimited, etc.).
|
||||||
pub const DataError = @import("service.zig").DataError;
|
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.
|
/// Company overview data (sector, industry, country, market cap) from Alpha Vantage.
|
||||||
pub const CompanyOverview = @import("service.zig").CompanyOverview;
|
pub const CompanyOverview = @import("service.zig").CompanyOverview;
|
||||||
|
|
||||||
|
|
|
||||||
430
src/service.zig
430
src/service.zig
|
|
@ -62,6 +62,38 @@ pub const DataError = error{
|
||||||
AuthError,
|
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
|
/// Decide whether a provider failure is permanent enough to merit a
|
||||||
/// negative-cache entry. Negative entries suppress retries until the
|
/// negative-cache entry. Negative entries suppress retries until the
|
||||||
/// next manual `--refresh-data=force` / `cache clear`, so writing one is only
|
/// next manual `--refresh-data=force` / `cache clear`, so writing one is only
|
||||||
|
|
@ -202,6 +234,13 @@ pub const DataService = struct {
|
||||||
yh: ?Yahoo = null,
|
yh: ?Yahoo = null,
|
||||||
tg: ?Tiingo = 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 {
|
pub fn init(io: std.Io, allocator: std.mem.Allocator, config: Config) DataService {
|
||||||
const self = DataService{
|
const self = DataService{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
|
|
@ -299,22 +338,42 @@ pub const DataService = struct {
|
||||||
/// Checks cache first; on miss, fetches from the appropriate provider,
|
/// Checks cache first; on miss, fetches from the appropriate provider,
|
||||||
/// writes to cache, and returns. On permanent fetch failure, writes a negative
|
/// writes to cache, and returns. On permanent fetch failure, writes a negative
|
||||||
/// cache entry. Rate limit failures are retried once.
|
/// 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(
|
fn fetchCached(
|
||||||
self: *DataService,
|
self: *DataService,
|
||||||
comptime T: type,
|
comptime T: type,
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
|
comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
|
||||||
|
opts: FetchOptions,
|
||||||
) DataError!FetchResult(T) {
|
) DataError!FetchResult(T) {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const data_type = comptime cache.Store.dataTypeFor(T);
|
const data_type = comptime cache.Store.dataTypeFor(T);
|
||||||
|
|
||||||
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
|
// Force-refresh skips the fresh-cache early return; falls
|
||||||
log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) });
|
// through to provider fetch. Skip-network does the opposite:
|
||||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
// 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 (opts.skip_network) {
|
||||||
if (self.syncFromServer(symbol, data_type)) {
|
// 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| {
|
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
|
||||||
log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) });
|
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 };
|
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) });
|
log.debug("{s}: fetching {s} from provider", .{ symbol, @tagName(data_type) });
|
||||||
|
self.assertNetworkAllowed("fetchCached fetchFromProvider");
|
||||||
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
|
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
|
||||||
if (err == error.RateLimited) {
|
if (err == error.RateLimited) {
|
||||||
// Wait and retry once
|
// Wait and retry once
|
||||||
|
|
@ -556,12 +616,27 @@ pub const DataService = struct {
|
||||||
err == error.RequestFailed;
|
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).
|
/// Fetch daily candles for a symbol (10+ years for trailing returns).
|
||||||
/// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
|
/// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
|
||||||
/// Uses incremental updates: when the cache is stale, only fetches
|
/// Uses incremental updates: when the cache is stale, only fetches
|
||||||
/// candles newer than the last cached date rather than re-fetching
|
/// candles newer than the last cached date rather than re-fetching
|
||||||
/// the entire history.
|
/// 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();
|
var s = self.store();
|
||||||
const today = fmt.todayDate(self.io);
|
const today = fmt.todayDate(self.io);
|
||||||
|
|
||||||
|
|
@ -570,18 +645,37 @@ pub const DataService = struct {
|
||||||
if (meta_result) |mr| {
|
if (meta_result) |mr| {
|
||||||
const m = mr.meta;
|
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
|
// If cached data is from TwelveData (deprecated for candles due to
|
||||||
// unreliable adj_close), skip cache and fall through to full re-fetch.
|
// unreliable adj_close), skip cache and fall through to full re-fetch.
|
||||||
if (m.provider == .twelvedata) {
|
if (m.provider == .twelvedata) {
|
||||||
log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol});
|
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
|
// Fresh — deserialize candles and return
|
||||||
log.debug("{s}: candles fresh in local cache", .{symbol});
|
log.debug("{s}: candles fresh in local cache", .{symbol});
|
||||||
if (s.read(Candle, symbol, null, .any)) |r|
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
|
||||||
} else {
|
} else {
|
||||||
// Stale — try server sync before incremental fetch
|
// Stale — try server sync before incremental fetch.
|
||||||
if (self.syncCandlesFromServer(symbol)) {
|
// (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)) {
|
if (s.isCandleMetaFresh(symbol)) {
|
||||||
log.debug("{s}: candles synced from server and fresh", .{symbol});
|
log.debug("{s}: candles synced from server and fresh", .{symbol});
|
||||||
if (s.read(Candle, symbol, null, .any)) |r|
|
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 };
|
return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
|
||||||
} else {
|
} else {
|
||||||
// Incremental fetch from day after last cached candle
|
// 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| {
|
const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| {
|
||||||
if (err == DataError.TransientError) {
|
if (err == DataError.TransientError) {
|
||||||
// Increment fail_count for this symbol
|
// Increment fail_count for this symbol
|
||||||
|
|
@ -641,8 +736,14 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable cache — try server sync first
|
// Offline mode + no usable cache — give up.
|
||||||
if (self.syncCandlesFromServer(symbol)) {
|
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)) {
|
if (s.isCandleMetaFresh(symbol)) {
|
||||||
log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol});
|
log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol});
|
||||||
if (s.read(Candle, symbol, null, .any)) |r|
|
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
|
// history, plus a buffer for older corporate actions like
|
||||||
// SPYM's 2017-10-16 split.
|
// SPYM's 2017-10-16 split.
|
||||||
log.debug("{s}: fetching full candle history from provider", .{symbol});
|
log.debug("{s}: fetching full candle history from provider", .{symbol});
|
||||||
|
self.assertNetworkAllowed("getCandles full populateAllFromTiingo");
|
||||||
|
|
||||||
const triple = self.populateAllFromTiingo(symbol) catch |err| {
|
const triple = self.populateAllFromTiingo(symbol) catch |err| {
|
||||||
if (err == error.RateLimited or err == error.ServerError or err == error.RequestFailed) {
|
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.
|
/// Fetch dividend history for a symbol.
|
||||||
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
|
pub fn getDividends(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Dividend) {
|
||||||
return self.fetchCached(Dividend, symbol, dividendPostProcess);
|
return self.fetchCached(Dividend, symbol, dividendPostProcess, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch split history for a symbol.
|
/// Fetch split history for a symbol.
|
||||||
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
|
pub fn getSplits(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Split) {
|
||||||
return self.fetchCached(Split, symbol, null);
|
return self.fetchCached(Split, symbol, null, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch options chain for a symbol (all expirations, no API key needed).
|
/// Fetch options chain for a symbol (all expirations, no API key needed).
|
||||||
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
|
pub fn getOptions(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(OptionsChain) {
|
||||||
return self.fetchCached(OptionsChain, symbol, null);
|
return self.fetchCached(OptionsChain, symbol, null, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch earnings history for a symbol.
|
/// Fetch earnings history for a symbol.
|
||||||
/// Checks cache first; fetches from FMP if stale/missing.
|
/// Checks cache first; fetches from FMP if stale/missing.
|
||||||
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
|
/// 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).
|
/// 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.
|
// Mutual funds (5-letter tickers ending in X) don't have quarterly earnings.
|
||||||
if (isMutualFund(symbol)) {
|
if (isMutualFund(symbol)) {
|
||||||
return .{ .data = &.{}, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
|
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();
|
var s = self.store();
|
||||||
const today = fmt.todayDate(self.io);
|
const today = fmt.todayDate(self.io);
|
||||||
|
|
||||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
if (!opts.force_refresh) {
|
||||||
// Check if any past/today earnings event is still missing actual results.
|
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||||
// If so, the announcement likely just happened — force a refresh.
|
// Check if any past/today earnings event is still missing actual results.
|
||||||
const needs_refresh = for (cached.data) |ev| {
|
// If so, the announcement likely just happened — force a refresh.
|
||||||
if (ev.actual == null and !today.lessThan(ev.date)) break true;
|
// (Suppressed when opts.skip_network — offline mode never refetches.)
|
||||||
} else false;
|
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) {
|
if (!needs_refresh) {
|
||||||
log.debug("{s}: earnings fresh in local cache", .{symbol});
|
log.debug("{s}: earnings fresh in local cache", .{symbol});
|
||||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
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 (opts.skip_network) {
|
||||||
if (self.syncFromServer(symbol, .earnings)) {
|
// 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| {
|
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||||
log.debug("{s}: earnings synced from server and fresh", .{symbol});
|
log.debug("{s}: earnings synced from server and fresh", .{symbol});
|
||||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
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});
|
log.debug("{s}: fetching earnings from provider", .{symbol});
|
||||||
|
self.assertNetworkAllowed("getEarnings fmp.fetchEarnings");
|
||||||
var fmp = try self.getProvider(Fmp);
|
var fmp = try self.getProvider(Fmp);
|
||||||
|
|
||||||
const fetched = fmp.fetchEarnings(self.allocator, symbol) catch |err| blk: {
|
const fetched = fmp.fetchEarnings(self.allocator, symbol) catch |err| blk: {
|
||||||
|
|
@ -761,12 +880,27 @@ pub const DataService = struct {
|
||||||
|
|
||||||
/// Fetch ETF profile for a symbol.
|
/// Fetch ETF profile for a symbol.
|
||||||
/// Checks cache first; fetches from Alpha Vantage if stale/missing.
|
/// 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();
|
var s = self.store();
|
||||||
|
|
||||||
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
|
if (!opts.force_refresh) {
|
||||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
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);
|
var av = try self.getProvider(AlphaVantage);
|
||||||
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
|
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
|
||||||
if (err == error.RateLimited) {
|
if (err == error.RateLimited) {
|
||||||
|
|
@ -789,7 +923,19 @@ pub const DataService = struct {
|
||||||
/// Fetch a real-time quote for a symbol.
|
/// Fetch a real-time quote for a symbol.
|
||||||
/// Yahoo Finance is primary (free, no API key, no 15-min delay).
|
/// Yahoo Finance is primary (free, no API key, no 15-min delay).
|
||||||
/// Falls back to TwelveData if Yahoo fails.
|
/// 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)
|
// Primary: Yahoo Finance (free, real-time)
|
||||||
if (self.getProvider(Yahoo)) |yh| {
|
if (self.getProvider(Yahoo)) |yh| {
|
||||||
if (yh.fetchQuote(self.allocator, symbol)) |quote| {
|
if (yh.fetchQuote(self.allocator, symbol)) |quote| {
|
||||||
|
|
@ -827,7 +973,7 @@ pub const DataService = struct {
|
||||||
/// publish). `*_total` columns include dividend reinvestment (matches Morningstar
|
/// publish). `*_total` columns include dividend reinvestment (matches Morningstar
|
||||||
/// "Trailing Returns" / Yahoo "Performance Overview" / Koyfin "Total Return").
|
/// "Trailing Returns" / Yahoo "Performance Overview" / Koyfin "Total Return").
|
||||||
/// See `tmp/multi-ticker-audit.md` for the cross-validation evidence.
|
/// 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_price: performance.TrailingReturns,
|
||||||
asof_total: ?performance.TrailingReturns,
|
asof_total: ?performance.TrailingReturns,
|
||||||
me_price: performance.TrailingReturns,
|
me_price: performance.TrailingReturns,
|
||||||
|
|
@ -837,7 +983,7 @@ pub const DataService = struct {
|
||||||
source: Source,
|
source: Source,
|
||||||
timestamp: i64,
|
timestamp: i64,
|
||||||
} {
|
} {
|
||||||
const candle_result = try self.getCandles(symbol);
|
const candle_result = try self.getCandles(symbol, opts);
|
||||||
const c = candle_result.data;
|
const c = candle_result.data;
|
||||||
if (c.len == 0) return DataError.FetchFailed;
|
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).
|
// tickers with no splits in the window (i.e. most of them).
|
||||||
var splits_buf: ?FetchResult(Split) = null;
|
var splits_buf: ?FetchResult(Split) = null;
|
||||||
defer if (splits_buf) |sb| sb.deinit();
|
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;
|
splits_buf = sr;
|
||||||
break :blk sr.data;
|
break :blk sr.data;
|
||||||
} else |_| &.{};
|
} else |_| &.{};
|
||||||
|
|
@ -875,7 +1021,7 @@ pub const DataService = struct {
|
||||||
const asof_adj = performance.trailingReturns(c);
|
const asof_adj = performance.trailingReturns(c);
|
||||||
const me_adj = performance.trailingReturnsMonthEnd(c, today);
|
const me_adj = performance.trailingReturnsMonthEnd(c, today);
|
||||||
|
|
||||||
if (self.getDividends(symbol)) |div_result| {
|
if (self.getDividends(symbol, opts)) |div_result| {
|
||||||
divs = div_result.data;
|
divs = div_result.data;
|
||||||
const asof_div = performance.trailingReturnsWithDividends(c, div_result.data);
|
const asof_div = performance.trailingReturnsWithDividends(c, div_result.data);
|
||||||
const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
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);
|
if (progress) |p| p.emit(i, total, sym, .fetching);
|
||||||
|
|
||||||
// 2. Try API fetch
|
// 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);
|
defer self.allocator.free(candle_result.data);
|
||||||
if (candle_result.data.len > 0) {
|
if (candle_result.data.len > 0) {
|
||||||
const last = candle_result.data[candle_result.data.len - 1];
|
const last = candle_result.data[candle_result.data.len - 1];
|
||||||
|
|
@ -1088,9 +1234,20 @@ pub const DataService = struct {
|
||||||
/// Configuration for loadAllPrices.
|
/// Configuration for loadAllPrices.
|
||||||
pub const LoadAllConfig = struct {
|
pub const LoadAllConfig = struct {
|
||||||
force_refresh: bool = false,
|
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,
|
color: bool = true,
|
||||||
/// Maximum concurrent server sync requests. 0 = auto (8).
|
/// Maximum concurrent server sync requests. 0 = auto (8).
|
||||||
max_concurrent: usize = 0,
|
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.
|
/// Result of loadAllPrices operation.
|
||||||
|
|
@ -1234,6 +1391,23 @@ pub const DataService = struct {
|
||||||
return result;
|
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)
|
// Phase 2: Server sync (parallel if server configured)
|
||||||
var server_failures: std.ArrayList([]const u8) = .empty;
|
var server_failures: std.ArrayList([]const u8) = .empty;
|
||||||
defer server_failures.deinit(self.allocator);
|
defer server_failures.deinit(self.allocator);
|
||||||
|
|
@ -1267,6 +1441,7 @@ pub const DataService = struct {
|
||||||
&result,
|
&result,
|
||||||
symbol_progress,
|
symbol_progress,
|
||||||
total_count - server_failures.items.len, // offset for progress display
|
total_count - server_failures.items.len, // offset for progress display
|
||||||
|
config.fetchOptions(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1386,6 +1561,7 @@ pub const DataService = struct {
|
||||||
result: *LoadAllResult,
|
result: *LoadAllResult,
|
||||||
progress: ?ProgressCallback,
|
progress: ?ProgressCallback,
|
||||||
index_offset: usize,
|
index_offset: usize,
|
||||||
|
opts: FetchOptions,
|
||||||
) void {
|
) void {
|
||||||
const total = index_offset + symbols.len;
|
const total = index_offset + symbols.len;
|
||||||
|
|
||||||
|
|
@ -1396,7 +1572,7 @@ pub const DataService = struct {
|
||||||
if (progress) |p| p.emit(display_idx, total, sym, .fetching);
|
if (progress) |p| p.emit(display_idx, total, sym, .fetching);
|
||||||
|
|
||||||
// Try provider fetch
|
// Try provider fetch
|
||||||
if (self.getCandles(sym)) |candle_result| {
|
if (self.getCandles(sym, opts)) |candle_result| {
|
||||||
defer self.allocator.free(candle_result.data);
|
defer self.allocator.free(candle_result.data);
|
||||||
if (candle_result.data.len > 0) {
|
if (candle_result.data.len > 0) {
|
||||||
const last = candle_result.data[candle_result.data.len - 1];
|
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.expect(div_result.source == .fetched);
|
||||||
try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1178,7 +1178,7 @@ pub const App = struct {
|
||||||
switch (self.active_tab) {
|
switch (self.active_tab) {
|
||||||
.quote, .performance => {
|
.quote, .performance => {
|
||||||
if (self.symbol.len > 0) {
|
if (self.symbol.len > 0) {
|
||||||
if (self.svc.getQuote(self.symbol)) |q| {
|
if (self.svc.getQuote(self.symbol, .{})) |q| {
|
||||||
self.states.quote.live = q;
|
self.states.quote.live = q;
|
||||||
// wall-clock required: records the exact moment
|
// wall-clock required: records the exact moment
|
||||||
// this quote was served so the "refreshed Xs ago"
|
// this quote was served so the "refreshed Xs ago"
|
||||||
|
|
@ -1284,7 +1284,7 @@ pub const App = struct {
|
||||||
var wp = &(self.portfolio.watchlist_prices.?);
|
var wp = &(self.portfolio.watchlist_prices.?);
|
||||||
if (self.watchlist) |wl| {
|
if (self.watchlist) |wl| {
|
||||||
for (wl) |sym| {
|
for (wl) |sym| {
|
||||||
const result = self.svc.getCandles(sym) catch continue;
|
const result = self.svc.getCandles(sym, .{}) catch continue;
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
if (result.data.len > 0) {
|
if (result.data.len > 0) {
|
||||||
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
||||||
|
|
@ -1294,7 +1294,7 @@ pub const App = struct {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .watch) {
|
if (lot.security_type == .watch) {
|
||||||
const sym = lot.priceSymbol();
|
const sym = lot.priceSymbol();
|
||||||
const result = self.svc.getCandles(sym) catch continue;
|
const result = self.svc.getCandles(sym, .{}) catch continue;
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
if (result.data.len > 0) {
|
if (result.data.len > 0) {
|
||||||
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ fn loadData(state: *State, app: *App) void {
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
state.error_msg = null;
|
state.error_msg = null;
|
||||||
|
|
||||||
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
const result = app.svc.getEarnings(app.symbol, .{}) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
zfin.DataError.NoApiKey => {
|
||||||
state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
|
state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ fn loadData(state: *State, app: *App) void {
|
||||||
}
|
}
|
||||||
state.chains = null;
|
state.chains = null;
|
||||||
|
|
||||||
const result = app.svc.getOptions(app.symbol) catch |err| {
|
const result = app.svc.getOptions(app.symbol, .{}) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
|
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
|
||||||
else => app.setStatus("Error loading options"),
|
else => app.setStatus("Error loading options"),
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ fn loadData(state: *State, app: *App) void {
|
||||||
app.symbol_data.trailing_me_price = null;
|
app.symbol_data.trailing_me_price = null;
|
||||||
app.symbol_data.trailing_me_total = 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) {
|
switch (err) {
|
||||||
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"),
|
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"),
|
||||||
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
|
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)
|
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||||
if (!app.symbol_data.etf_loaded) {
|
if (!app.symbol_data.etf_loaded) {
|
||||||
app.symbol_data.etf_loaded = true;
|
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()) {
|
if (etf_result.data.isEtf()) {
|
||||||
app.symbol_data.etf_profile = etf_result.data;
|
app.symbol_data.etf_profile = etf_result.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -705,14 +705,14 @@ fn buildContextFromParts(
|
||||||
// mode we slice to `<= as_of` — `performance.trailingReturns`
|
// mode we slice to `<= as_of` — `performance.trailingReturns`
|
||||||
// anchors on the last candle's date, so trimming the tail gives
|
// anchors on the last candle's date, so trimming the tail gives
|
||||||
// returns "as of" that date for free.
|
// 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();
|
defer if (spy_result) |r| r.deinit();
|
||||||
const spy_candles = history.sliceCandlesAsOf(
|
const spy_candles = history.sliceCandlesAsOf(
|
||||||
if (spy_result) |r| r.data else &.{},
|
if (spy_result) |r| r.data else &.{},
|
||||||
as_of,
|
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();
|
defer if (agg_result) |r| r.deinit();
|
||||||
const agg_candles = history.sliceCandlesAsOf(
|
const agg_candles = history.sliceCandlesAsOf(
|
||||||
if (agg_result) |r| r.data else &.{},
|
if (agg_result) |r| r.data else &.{},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue