create a set of fetch options and wire it into CLI for flags to work properly
This commit is contained in:
parent
7deb49254a
commit
c300729887
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.
|
||||
/// 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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 |_| {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
430
src/service.zig
430
src/service.zig
|
|
@ -62,6 +62,38 @@ pub const DataError = error{
|
|||
AuthError,
|
||||
};
|
||||
|
||||
/// Per-call options controlling cache vs network behavior. Drives
|
||||
/// the `--refresh-data` global flag's three modes:
|
||||
///
|
||||
/// - `--refresh-data=auto` → `.{}` (default; respect TTL, fetch on stale/miss).
|
||||
/// - `--refresh-data=never` → `.{ .skip_network = true }` (offline mode;
|
||||
/// return cached data even if stale, treat cache miss as unavailable).
|
||||
/// - `--refresh-data=force` → `.{ .force_refresh = true }` (ignore cache TTL,
|
||||
/// fetch fresh from provider).
|
||||
///
|
||||
/// `skip_network` and `force_refresh` represent contradictory intents.
|
||||
/// The CLI flag cannot produce the combination — `RefreshPolicy` is a
|
||||
/// 3-variant enum, so the user can never set both. But because the
|
||||
/// underlying shape is two independent booleans, an internal caller
|
||||
/// constructing `FetchOptions` directly *could* produce the
|
||||
/// combination. When both are true, **`skip_network` wins**:
|
||||
///
|
||||
/// - The call returns cached data (fresh or stale, whatever's there).
|
||||
/// - `force_refresh` has no effect — no network is touched.
|
||||
///
|
||||
/// This is the safe default: when in doubt, don't reach the network.
|
||||
/// Internal callers that genuinely want fresh data should set
|
||||
/// `force_refresh = true, skip_network = false`.
|
||||
pub const FetchOptions = struct {
|
||||
/// Skip provider fetches and server sync. Returns cached data
|
||||
/// (even if stale) or null/empty on cache miss. Wins over
|
||||
/// `force_refresh` when both are set.
|
||||
skip_network: bool = false,
|
||||
/// Force a fresh fetch ignoring cache TTL. No-op when
|
||||
/// `skip_network` is also set.
|
||||
force_refresh: bool = false,
|
||||
};
|
||||
|
||||
/// Decide whether a provider failure is permanent enough to merit a
|
||||
/// negative-cache entry. Negative entries suppress retries until the
|
||||
/// next manual `--refresh-data=force` / `cache clear`, so writing one is only
|
||||
|
|
@ -202,6 +234,13 @@ pub const DataService = struct {
|
|||
yh: ?Yahoo = null,
|
||||
tg: ?Tiingo = null,
|
||||
|
||||
/// Test-only guard: when true, any code path that would touch
|
||||
/// the network panics with a clear message. Used by offline-mode
|
||||
/// tests to verify that `FetchOptions.skip_network = true`
|
||||
/// genuinely doesn't reach the network. Default false; never
|
||||
/// set in production.
|
||||
panic_on_network_attempt: bool = false,
|
||||
|
||||
pub fn init(io: std.Io, allocator: std.mem.Allocator, config: Config) DataService {
|
||||
const self = DataService{
|
||||
.allocator = allocator,
|
||||
|
|
@ -299,22 +338,42 @@ pub const DataService = struct {
|
|||
/// Checks cache first; on miss, fetches from the appropriate provider,
|
||||
/// writes to cache, and returns. On permanent fetch failure, writes a negative
|
||||
/// cache entry. Rate limit failures are retried once.
|
||||
///
|
||||
/// `opts.skip_network = true` → returns cached data even if stale,
|
||||
/// returns FetchFailed on cache miss without touching the network.
|
||||
/// `opts.force_refresh = true` → treats cache as stale and fetches.
|
||||
fn fetchCached(
|
||||
self: *DataService,
|
||||
comptime T: type,
|
||||
symbol: []const u8,
|
||||
comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
|
||||
opts: FetchOptions,
|
||||
) DataError!FetchResult(T) {
|
||||
var s = self.store();
|
||||
const data_type = comptime cache.Store.dataTypeFor(T);
|
||||
|
||||
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
|
||||
log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) });
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
// Force-refresh skips the fresh-cache early return; falls
|
||||
// through to provider fetch. Skip-network does the opposite:
|
||||
// returns cached even if stale, never touches the network.
|
||||
if (!opts.force_refresh) {
|
||||
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
|
||||
log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) });
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
}
|
||||
|
||||
// Try server sync before hitting providers
|
||||
if (self.syncFromServer(symbol, data_type)) {
|
||||
if (opts.skip_network) {
|
||||
// Offline mode: return whatever's cached, even if stale.
|
||||
// Cache miss is FetchFailed (not a network error).
|
||||
if (s.read(T, symbol, postProcess, .any)) |cached| {
|
||||
log.info("{s}: {s} stale-cached returned (skip_network)", .{ symbol, @tagName(data_type) });
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
// Try server sync before hitting providers (skipped on force_refresh).
|
||||
if (!opts.force_refresh and self.syncFromServer(symbol, data_type)) {
|
||||
if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
|
||||
log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) });
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
|
|
@ -323,6 +382,7 @@ pub const DataService = struct {
|
|||
}
|
||||
|
||||
log.debug("{s}: fetching {s} from provider", .{ symbol, @tagName(data_type) });
|
||||
self.assertNetworkAllowed("fetchCached fetchFromProvider");
|
||||
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
|
||||
if (err == error.RateLimited) {
|
||||
// Wait and retry once
|
||||
|
|
@ -556,12 +616,27 @@ pub const DataService = struct {
|
|||
err == error.RequestFailed;
|
||||
}
|
||||
|
||||
/// Centralized "are we about to touch the network?" gate. Tests
|
||||
/// set `panic_on_network_attempt` to assert that offline-mode
|
||||
/// paths never reach this site. Production callers always pass.
|
||||
/// Inline so the panic body is only generated when the field is
|
||||
/// actually checked (no overhead on the false branch).
|
||||
inline fn assertNetworkAllowed(self: *DataService, context: []const u8) void {
|
||||
if (self.panic_on_network_attempt) {
|
||||
std.debug.panic("network attempted in offline-mode test: {s}", .{context});
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch daily candles for a symbol (10+ years for trailing returns).
|
||||
/// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
|
||||
/// Uses incremental updates: when the cache is stale, only fetches
|
||||
/// candles newer than the last cached date rather than re-fetching
|
||||
/// the entire history.
|
||||
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) {
|
||||
///
|
||||
/// `opts.skip_network = true` → returns cached data even if stale,
|
||||
/// returns FetchFailed on cache miss without touching the network.
|
||||
/// `opts.force_refresh = true` → treats cache as stale and fetches.
|
||||
pub fn getCandles(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Candle) {
|
||||
var s = self.store();
|
||||
const today = fmt.todayDate(self.io);
|
||||
|
||||
|
|
@ -570,18 +645,37 @@ pub const DataService = struct {
|
|||
if (meta_result) |mr| {
|
||||
const m = mr.meta;
|
||||
|
||||
// Offline mode: return cached data without touching the
|
||||
// network. Cache miss / TwelveData-only cache is treated
|
||||
// as unavailable.
|
||||
if (opts.skip_network) {
|
||||
if (m.provider == .twelvedata) {
|
||||
log.debug("{s}: skip_network and only TwelveData cached — treating as unavailable", .{symbol});
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
if (s.read(Candle, symbol, null, .any)) |r| {
|
||||
if (!s.isCandleMetaFresh(symbol)) {
|
||||
log.info("{s}: candles stale-cached returned (skip_network)", .{symbol});
|
||||
}
|
||||
return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
|
||||
}
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
// If cached data is from TwelveData (deprecated for candles due to
|
||||
// unreliable adj_close), skip cache and fall through to full re-fetch.
|
||||
if (m.provider == .twelvedata) {
|
||||
log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol});
|
||||
} else if (s.isCandleMetaFresh(symbol)) {
|
||||
} else if (!opts.force_refresh and s.isCandleMetaFresh(symbol)) {
|
||||
// Fresh — deserialize candles and return
|
||||
log.debug("{s}: candles fresh in local cache", .{symbol});
|
||||
if (s.read(Candle, symbol, null, .any)) |r|
|
||||
return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
|
||||
} else {
|
||||
// Stale — try server sync before incremental fetch
|
||||
if (self.syncCandlesFromServer(symbol)) {
|
||||
// Stale — try server sync before incremental fetch.
|
||||
// (Force-refresh skips server sync too: the user explicitly
|
||||
// asked for fresh provider data.)
|
||||
if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) {
|
||||
if (s.isCandleMetaFresh(symbol)) {
|
||||
log.debug("{s}: candles synced from server and fresh", .{symbol});
|
||||
if (s.read(Candle, symbol, null, .any)) |r|
|
||||
|
|
@ -600,6 +694,7 @@ pub const DataService = struct {
|
|||
return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
|
||||
} else {
|
||||
// Incremental fetch from day after last cached candle
|
||||
self.assertNetworkAllowed("getCandles incremental fetchCandlesFromProviders");
|
||||
const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| {
|
||||
if (err == DataError.TransientError) {
|
||||
// Increment fail_count for this symbol
|
||||
|
|
@ -641,8 +736,14 @@ pub const DataService = struct {
|
|||
}
|
||||
}
|
||||
|
||||
// No usable cache — try server sync first
|
||||
if (self.syncCandlesFromServer(symbol)) {
|
||||
// Offline mode + no usable cache — give up.
|
||||
if (opts.skip_network) {
|
||||
log.debug("{s}: skip_network and no cached candles — unavailable", .{symbol});
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
// No usable cache — try server sync first (skipped on force_refresh).
|
||||
if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) {
|
||||
if (s.isCandleMetaFresh(symbol)) {
|
||||
log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol});
|
||||
if (s.read(Candle, symbol, null, .any)) |r|
|
||||
|
|
@ -660,6 +761,7 @@ pub const DataService = struct {
|
|||
// history, plus a buffer for older corporate actions like
|
||||
// SPYM's 2017-10-16 split.
|
||||
log.debug("{s}: fetching full candle history from provider", .{symbol});
|
||||
self.assertNetworkAllowed("getCandles full populateAllFromTiingo");
|
||||
|
||||
const triple = self.populateAllFromTiingo(symbol) catch |err| {
|
||||
if (err == error.RateLimited or err == error.ServerError or err == error.RequestFailed) {
|
||||
|
|
@ -687,25 +789,29 @@ pub const DataService = struct {
|
|||
}
|
||||
|
||||
/// Fetch dividend history for a symbol.
|
||||
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
|
||||
return self.fetchCached(Dividend, symbol, dividendPostProcess);
|
||||
pub fn getDividends(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Dividend) {
|
||||
return self.fetchCached(Dividend, symbol, dividendPostProcess, opts);
|
||||
}
|
||||
|
||||
/// Fetch split history for a symbol.
|
||||
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
|
||||
return self.fetchCached(Split, symbol, null);
|
||||
pub fn getSplits(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Split) {
|
||||
return self.fetchCached(Split, symbol, null, opts);
|
||||
}
|
||||
|
||||
/// Fetch options chain for a symbol (all expirations, no API key needed).
|
||||
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
|
||||
return self.fetchCached(OptionsChain, symbol, null);
|
||||
pub fn getOptions(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(OptionsChain) {
|
||||
return self.fetchCached(OptionsChain, symbol, null, opts);
|
||||
}
|
||||
|
||||
/// Fetch earnings history for a symbol.
|
||||
/// Checks cache first; fetches from FMP if stale/missing.
|
||||
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
|
||||
/// date has no actual results yet (i.e. results just came out).
|
||||
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
|
||||
///
|
||||
/// `opts.skip_network = true` → returns cached data even if stale,
|
||||
/// returns FetchFailed on cache miss without touching the network.
|
||||
/// `opts.force_refresh = true` → treats cache as stale and fetches.
|
||||
pub fn getEarnings(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EarningsEvent) {
|
||||
// Mutual funds (5-letter tickers ending in X) don't have quarterly earnings.
|
||||
if (isMutualFund(symbol)) {
|
||||
return .{ .data = &.{}, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
|
||||
|
|
@ -714,23 +820,35 @@ pub const DataService = struct {
|
|||
var s = self.store();
|
||||
const today = fmt.todayDate(self.io);
|
||||
|
||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||
// Check if any past/today earnings event is still missing actual results.
|
||||
// If so, the announcement likely just happened — force a refresh.
|
||||
const needs_refresh = for (cached.data) |ev| {
|
||||
if (ev.actual == null and !today.lessThan(ev.date)) break true;
|
||||
} else false;
|
||||
if (!opts.force_refresh) {
|
||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||
// Check if any past/today earnings event is still missing actual results.
|
||||
// If so, the announcement likely just happened — force a refresh.
|
||||
// (Suppressed when opts.skip_network — offline mode never refetches.)
|
||||
const needs_refresh = if (opts.skip_network) false else for (cached.data) |ev| {
|
||||
if (ev.actual == null and !today.lessThan(ev.date)) break true;
|
||||
} else false;
|
||||
|
||||
if (!needs_refresh) {
|
||||
log.debug("{s}: earnings fresh in local cache", .{symbol});
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
if (!needs_refresh) {
|
||||
log.debug("{s}: earnings fresh in local cache", .{symbol});
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
// Stale: free cached events and re-fetch below
|
||||
self.allocator.free(cached.data);
|
||||
}
|
||||
// Stale: free cached events and re-fetch below
|
||||
self.allocator.free(cached.data);
|
||||
}
|
||||
|
||||
// Try server sync before hitting FMP
|
||||
if (self.syncFromServer(symbol, .earnings)) {
|
||||
if (opts.skip_network) {
|
||||
// Offline mode: fall back to any cached entry (even stale) before giving up.
|
||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .any)) |cached| {
|
||||
log.info("{s}: earnings stale-cached returned (skip_network)", .{symbol});
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
// Try server sync before hitting FMP (skipped on force_refresh).
|
||||
if (!opts.force_refresh and self.syncFromServer(symbol, .earnings)) {
|
||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||
log.debug("{s}: earnings synced from server and fresh", .{symbol});
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
|
|
@ -739,6 +857,7 @@ pub const DataService = struct {
|
|||
}
|
||||
|
||||
log.debug("{s}: fetching earnings from provider", .{symbol});
|
||||
self.assertNetworkAllowed("getEarnings fmp.fetchEarnings");
|
||||
var fmp = try self.getProvider(Fmp);
|
||||
|
||||
const fetched = fmp.fetchEarnings(self.allocator, symbol) catch |err| blk: {
|
||||
|
|
@ -761,12 +880,27 @@ pub const DataService = struct {
|
|||
|
||||
/// Fetch ETF profile for a symbol.
|
||||
/// Checks cache first; fetches from Alpha Vantage if stale/missing.
|
||||
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) {
|
||||
///
|
||||
/// `opts.skip_network = true` → returns cached data even if stale,
|
||||
/// returns FetchFailed on cache miss without touching the network.
|
||||
/// `opts.force_refresh = true` → treats cache as stale and fetches.
|
||||
pub fn getEtfProfile(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EtfProfile) {
|
||||
var s = self.store();
|
||||
|
||||
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
if (!opts.force_refresh) {
|
||||
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
|
||||
if (opts.skip_network) {
|
||||
if (s.read(EtfProfile, symbol, null, .any)) |cached| {
|
||||
log.info("{s}: etf_profile stale-cached returned (skip_network)", .{symbol});
|
||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
|
||||
}
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
self.assertNetworkAllowed("getEtfProfile av.fetchEtfProfile");
|
||||
var av = try self.getProvider(AlphaVantage);
|
||||
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
|
||||
if (err == error.RateLimited) {
|
||||
|
|
@ -789,7 +923,19 @@ pub const DataService = struct {
|
|||
/// Fetch a real-time quote for a symbol.
|
||||
/// Yahoo Finance is primary (free, no API key, no 15-min delay).
|
||||
/// Falls back to TwelveData if Yahoo fails.
|
||||
pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote {
|
||||
///
|
||||
/// Quotes are never cached, so `opts.force_refresh` is a no-op
|
||||
/// (every call goes to the provider). `opts.skip_network = true`
|
||||
/// returns FetchFailed unconditionally — there's no cached price
|
||||
/// to fall back to.
|
||||
pub fn getQuote(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!Quote {
|
||||
if (opts.skip_network) {
|
||||
log.debug("{s}: skip_network — quote unavailable (never cached)", .{symbol});
|
||||
return DataError.FetchFailed;
|
||||
}
|
||||
|
||||
self.assertNetworkAllowed("getQuote");
|
||||
|
||||
// Primary: Yahoo Finance (free, real-time)
|
||||
if (self.getProvider(Yahoo)) |yh| {
|
||||
if (yh.fetchQuote(self.allocator, symbol)) |quote| {
|
||||
|
|
@ -827,7 +973,7 @@ pub const DataService = struct {
|
|||
/// publish). `*_total` columns include dividend reinvestment (matches Morningstar
|
||||
/// "Trailing Returns" / Yahoo "Performance Overview" / Koyfin "Total Return").
|
||||
/// See `tmp/multi-ticker-audit.md` for the cross-validation evidence.
|
||||
pub fn getTrailingReturns(self: *DataService, symbol: []const u8) DataError!struct {
|
||||
pub fn getTrailingReturns(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!struct {
|
||||
asof_price: performance.TrailingReturns,
|
||||
asof_total: ?performance.TrailingReturns,
|
||||
me_price: performance.TrailingReturns,
|
||||
|
|
@ -837,7 +983,7 @@ pub const DataService = struct {
|
|||
source: Source,
|
||||
timestamp: i64,
|
||||
} {
|
||||
const candle_result = try self.getCandles(symbol);
|
||||
const candle_result = try self.getCandles(symbol, opts);
|
||||
const c = candle_result.data;
|
||||
if (c.len == 0) return DataError.FetchFailed;
|
||||
|
||||
|
|
@ -850,7 +996,7 @@ pub const DataService = struct {
|
|||
// tickers with no splits in the window (i.e. most of them).
|
||||
var splits_buf: ?FetchResult(Split) = null;
|
||||
defer if (splits_buf) |sb| sb.deinit();
|
||||
const splits: []const Split = if (self.getSplits(symbol)) |sr| blk: {
|
||||
const splits: []const Split = if (self.getSplits(symbol, opts)) |sr| blk: {
|
||||
splits_buf = sr;
|
||||
break :blk sr.data;
|
||||
} else |_| &.{};
|
||||
|
|
@ -875,7 +1021,7 @@ pub const DataService = struct {
|
|||
const asof_adj = performance.trailingReturns(c);
|
||||
const me_adj = performance.trailingReturnsMonthEnd(c, today);
|
||||
|
||||
if (self.getDividends(symbol)) |div_result| {
|
||||
if (self.getDividends(symbol, opts)) |div_result| {
|
||||
divs = div_result.data;
|
||||
const asof_div = performance.trailingReturnsWithDividends(c, div_result.data);
|
||||
const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||
|
|
@ -1055,7 +1201,7 @@ pub const DataService = struct {
|
|||
if (progress) |p| p.emit(i, total, sym, .fetching);
|
||||
|
||||
// 2. Try API fetch
|
||||
if (self.getCandles(sym)) |candle_result| {
|
||||
if (self.getCandles(sym, .{ .force_refresh = force_refresh })) |candle_result| {
|
||||
defer self.allocator.free(candle_result.data);
|
||||
if (candle_result.data.len > 0) {
|
||||
const last = candle_result.data[candle_result.data.len - 1];
|
||||
|
|
@ -1088,9 +1234,20 @@ pub const DataService = struct {
|
|||
/// Configuration for loadAllPrices.
|
||||
pub const LoadAllConfig = struct {
|
||||
force_refresh: bool = false,
|
||||
/// Skip provider fetches and server sync. Returns cached
|
||||
/// data (even if stale) and treats cache miss as failure.
|
||||
/// Drives `--refresh-data=never`.
|
||||
skip_network: bool = false,
|
||||
color: bool = true,
|
||||
/// Maximum concurrent server sync requests. 0 = auto (8).
|
||||
max_concurrent: usize = 0,
|
||||
|
||||
/// Map this config to the per-call `FetchOptions` shape.
|
||||
/// Convenience for paths that need to pass through to
|
||||
/// `getCandles`/`getDividends`/etc.
|
||||
pub fn fetchOptions(self: LoadAllConfig) FetchOptions {
|
||||
return .{ .skip_network = self.skip_network, .force_refresh = self.force_refresh };
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of loadAllPrices operation.
|
||||
|
|
@ -1234,6 +1391,23 @@ pub const DataService = struct {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Offline mode: skip server sync and provider fetch entirely.
|
||||
// For symbols without a fresh cache, fall back to stale cache
|
||||
// before giving up.
|
||||
if (config.skip_network) {
|
||||
for (needs_fetch.items) |sym| {
|
||||
if (self.getCachedLastClose(sym)) |close| {
|
||||
result.prices.put(sym, close) catch {};
|
||||
self.updateLatestDate(&result, sym);
|
||||
result.stale_count += 1;
|
||||
} else {
|
||||
result.failed_count += 1;
|
||||
}
|
||||
}
|
||||
if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 2: Server sync (parallel if server configured)
|
||||
var server_failures: std.ArrayList([]const u8) = .empty;
|
||||
defer server_failures.deinit(self.allocator);
|
||||
|
|
@ -1267,6 +1441,7 @@ pub const DataService = struct {
|
|||
&result,
|
||||
symbol_progress,
|
||||
total_count - server_failures.items.len, // offset for progress display
|
||||
config.fetchOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1386,6 +1561,7 @@ pub const DataService = struct {
|
|||
result: *LoadAllResult,
|
||||
progress: ?ProgressCallback,
|
||||
index_offset: usize,
|
||||
opts: FetchOptions,
|
||||
) void {
|
||||
const total = index_offset + symbols.len;
|
||||
|
||||
|
|
@ -1396,7 +1572,7 @@ pub const DataService = struct {
|
|||
if (progress) |p| p.emit(display_idx, total, sym, .fetching);
|
||||
|
||||
// Try provider fetch
|
||||
if (self.getCandles(sym)) |candle_result| {
|
||||
if (self.getCandles(sym, opts)) |candle_result| {
|
||||
defer self.allocator.free(candle_result.data);
|
||||
if (candle_result.data.len > 0) {
|
||||
const last = candle_result.data[candle_result.data.len - 1];
|
||||
|
|
@ -1958,3 +2134,175 @@ test "FetchResult type construction" {
|
|||
try std.testing.expect(div_result.source == .fetched);
|
||||
try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp);
|
||||
}
|
||||
|
||||
test "FetchOptions default is fully permissive" {
|
||||
// Default-init should allow normal fetch behavior.
|
||||
const opts: FetchOptions = .{};
|
||||
try std.testing.expect(!opts.skip_network);
|
||||
try std.testing.expect(!opts.force_refresh);
|
||||
}
|
||||
|
||||
test "LoadAllConfig.fetchOptions maps fields through" {
|
||||
const cfg = DataService.LoadAllConfig{
|
||||
.force_refresh = true,
|
||||
.skip_network = false,
|
||||
};
|
||||
const opts = cfg.fetchOptions();
|
||||
try std.testing.expect(opts.force_refresh);
|
||||
try std.testing.expect(!opts.skip_network);
|
||||
|
||||
const cfg2 = DataService.LoadAllConfig{
|
||||
.skip_network = true,
|
||||
};
|
||||
const opts2 = cfg2.fetchOptions();
|
||||
try std.testing.expect(opts2.skip_network);
|
||||
try std.testing.expect(!opts2.force_refresh);
|
||||
}
|
||||
|
||||
test "getCandles offline mode returns cached data without network" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
// Construct a service with a cache pre-populated with candle data.
|
||||
const config = Config{ .cache_dir = dir_path };
|
||||
var svc = DataService.init(io, allocator, config);
|
||||
defer svc.deinit();
|
||||
|
||||
// Pre-populate cache via the Store API.
|
||||
var store = svc.store();
|
||||
var candles = [_]Candle{
|
||||
.{ .date = Date.fromYmd(2026, 5, 19), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 },
|
||||
.{ .date = Date.fromYmd(2026, 5, 20), .open = 104, .high = 106, .low = 103, .close = 105, .adj_close = 105, .volume = 1100 },
|
||||
};
|
||||
store.cacheCandles("TEST", candles[0..], .tiingo, 0);
|
||||
|
||||
// Set the test guard: any network call would panic. We expect
|
||||
// the offline-mode path NOT to touch the network.
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
const result = try svc.getCandles("TEST", .{ .skip_network = true });
|
||||
defer result.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), result.data.len);
|
||||
try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 5, 19)));
|
||||
try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2026, 5, 20)));
|
||||
try std.testing.expectEqual(Source.cached, result.source);
|
||||
}
|
||||
|
||||
test "getCandles offline mode with no cache returns FetchFailed" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const config = Config{ .cache_dir = dir_path };
|
||||
var svc = DataService.init(io, allocator, config);
|
||||
defer svc.deinit();
|
||||
|
||||
// Network guard is on. With no cache and skip_network=true,
|
||||
// we must return FetchFailed without panicking.
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
const err = svc.getCandles("NEVERHEARDOFIT", .{ .skip_network = true });
|
||||
try std.testing.expectError(DataError.FetchFailed, err);
|
||||
}
|
||||
|
||||
test "fetchCached offline mode returns stale-cached data" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const config = Config{ .cache_dir = dir_path };
|
||||
var svc = DataService.init(io, allocator, config);
|
||||
defer svc.deinit();
|
||||
|
||||
// Pre-populate dividend cache with a TTL in the past (stale).
|
||||
var store = svc.store();
|
||||
var divs = [_]Dividend{
|
||||
.{ .ex_date = Date.fromYmd(2026, 3, 15), .amount = 0.50, .type = .regular },
|
||||
};
|
||||
// Manually set TTL to 1 second (long since expired) by writing
|
||||
// through writeWithSource with a tiny TTL.
|
||||
store.writeWithSource(Dividend, "TEST", divs[0..], -1_000_000, "test");
|
||||
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
// Even though the cache is stale, skip_network must return it
|
||||
// rather than touching the network.
|
||||
const result = try svc.getDividends("TEST", .{ .skip_network = true });
|
||||
defer result.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), result.data.len);
|
||||
try std.testing.expectEqual(Source.cached, result.source);
|
||||
}
|
||||
|
||||
test "getQuote offline mode returns FetchFailed (quotes never cached)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const config = Config{ .cache_dir = dir_path };
|
||||
var svc = DataService.init(io, allocator, config);
|
||||
defer svc.deinit();
|
||||
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
// Quotes have no cache to fall back to in offline mode.
|
||||
const err = svc.getQuote("AAPL", .{ .skip_network = true });
|
||||
try std.testing.expectError(DataError.FetchFailed, err);
|
||||
}
|
||||
|
||||
test "loadAllPrices offline mode skips network and returns cached" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const config = Config{ .cache_dir = dir_path };
|
||||
var svc = DataService.init(io, allocator, config);
|
||||
defer svc.deinit();
|
||||
|
||||
var store = svc.store();
|
||||
// Symbol with fresh cache.
|
||||
var fresh_candles = [_]Candle{
|
||||
.{ .date = Date.fromYmd(2026, 5, 20), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 },
|
||||
};
|
||||
store.cacheCandles("FRESH", fresh_candles[0..], .tiingo, 0);
|
||||
|
||||
// Symbol with no cache at all.
|
||||
// (no setup needed — just passes a symbol that doesn't exist)
|
||||
|
||||
svc.panic_on_network_attempt = true;
|
||||
|
||||
const symbols = [_][]const u8{ "FRESH", "MISSING" };
|
||||
var result = svc.loadAllPrices(
|
||||
symbols[0..],
|
||||
&.{},
|
||||
.{ .skip_network = true },
|
||||
null,
|
||||
null,
|
||||
);
|
||||
defer result.prices.deinit();
|
||||
|
||||
// FRESH should resolve from cache.
|
||||
try std.testing.expect(result.prices.contains("FRESH"));
|
||||
try std.testing.expectEqual(@as(f64, 104), result.prices.get("FRESH").?);
|
||||
// MISSING should not be in the prices map.
|
||||
try std.testing.expect(!result.prices.contains("MISSING"));
|
||||
// failed_count should reflect MISSING.
|
||||
try std.testing.expectEqual(@as(usize, 1), result.failed_count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &.{},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue