Compare commits
No commits in common. "abde6fdf3b6f4fe65859533e8b05ed3d43b4cf3a" and "2f49a99ca420c9fa3d8c5ede5a52a2c37bc1ec1a" have entirely different histories.
abde6fdf3b
...
2f49a99ca4
4 changed files with 2 additions and 127 deletions
22
TODO.md
22
TODO.md
|
|
@ -58,28 +58,6 @@ exported as a public constant. Callers currently pass the default.
|
||||||
`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider
|
`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider
|
||||||
making this a config value (env var or .env) so it doesn't require a rebuild.
|
making this a config value (env var or .env) so it doesn't require a rebuild.
|
||||||
|
|
||||||
## On-demand server-side fetch for new symbols
|
|
||||||
|
|
||||||
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
|
|
||||||
cache reads — they 404 if the data isn't already on disk. New symbols only get
|
|
||||||
populated when added to the portfolio and picked up by the next cron refresh.
|
|
||||||
|
|
||||||
Consider: on a cache miss, instead of blocking the HTTP response with a
|
|
||||||
multi-second provider fetch, kick off an async background fetch (or just
|
|
||||||
auto-add the symbol to the portfolio) and return 404 as usual. The next
|
|
||||||
request — or the next cron run — would then have the data. This gives
|
|
||||||
"instant-ish gratification" for new symbols without the downsides of
|
|
||||||
synchronous fetch-on-miss (latency, rate limit contention, unbounded cache
|
|
||||||
growth from arbitrary tickers).
|
|
||||||
|
|
||||||
Note that this process doesn't do anything to eliminate all the API keys
|
|
||||||
that are necessary for a fully functioning system. A more aggressive view
|
|
||||||
would be to treat ZFIN_SERVER has a 100% record of reference, but that would
|
|
||||||
introduce some opacity to the process as we wait for candles (for example) to
|
|
||||||
populate. This could be solved on the server by spawning a thread to fetch the
|
|
||||||
data, then returning 202 Accepted, which could then be polled client side. Maybe
|
|
||||||
this is a better long term approach?
|
|
||||||
|
|
||||||
## CLI/TUI code review (lower priority)
|
## CLI/TUI code review (lower priority)
|
||||||
|
|
||||||
No review has been done on these files. They are presentation-layer code
|
No review has been done on these files. They are presentation-layer code
|
||||||
|
|
|
||||||
4
src/cache/store.zig
vendored
4
src/cache/store.zig
vendored
|
|
@ -374,9 +374,7 @@ pub const Store = struct {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write raw bytes to a cache file. Used by server sync to write
|
fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
||||||
/// pre-serialized SRF data directly to the cache.
|
|
||||||
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
|
||||||
try self.ensureSymbolDir(symbol);
|
try self.ensureSymbolDir(symbol);
|
||||||
const path = try self.symbolPath(symbol, data_type.fileName());
|
const path = try self.symbolPath(symbol, data_type.fileName());
|
||||||
defer self.allocator.free(path);
|
defer self.allocator.free(path);
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ pub const Config = struct {
|
||||||
finnhub_key: ?[]const u8 = null,
|
finnhub_key: ?[]const u8 = null,
|
||||||
alphavantage_key: ?[]const u8 = null,
|
alphavantage_key: ?[]const u8 = null,
|
||||||
openfigi_key: ?[]const u8 = null,
|
openfigi_key: ?[]const u8 = null,
|
||||||
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
|
|
||||||
server_url: ?[]const u8 = null,
|
|
||||||
cache_dir: []const u8,
|
cache_dir: []const u8,
|
||||||
allocator: ?std.mem.Allocator = null,
|
allocator: ?std.mem.Allocator = null,
|
||||||
/// Raw .env file contents (keys/values in env_map point into this).
|
/// Raw .env file contents (keys/values in env_map point into this).
|
||||||
|
|
@ -34,7 +32,6 @@ pub const Config = struct {
|
||||||
self.finnhub_key = self.resolve("FINNHUB_API_KEY");
|
self.finnhub_key = self.resolve("FINNHUB_API_KEY");
|
||||||
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
|
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
|
||||||
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
|
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
|
||||||
self.server_url = self.resolve("ZFIN_SERVER");
|
|
||||||
|
|
||||||
const env_cache = self.resolve("ZFIN_CACHE_DIR");
|
const env_cache = self.resolve("ZFIN_CACHE_DIR");
|
||||||
self.cache_dir = env_cache orelse blk: {
|
self.cache_dir = env_cache orelse blk: {
|
||||||
|
|
|
||||||
100
src/service.zig
100
src/service.zig
|
|
@ -8,7 +8,6 @@
|
||||||
//! based on available API keys. Callers never need to know which provider was used.
|
//! based on available API keys. Callers never need to know which provider was used.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const log = std.log.scoped(.service);
|
|
||||||
const Date = @import("models/date.zig").Date;
|
const Date = @import("models/date.zig").Date;
|
||||||
const Candle = @import("models/candle.zig").Candle;
|
const Candle = @import("models/candle.zig").Candle;
|
||||||
const Dividend = @import("models/dividend.zig").Dividend;
|
const Dividend = @import("models/dividend.zig").Dividend;
|
||||||
|
|
@ -29,7 +28,6 @@ const alphavantage = @import("providers/alphavantage.zig");
|
||||||
const OpenFigi = @import("providers/openfigi.zig");
|
const OpenFigi = @import("providers/openfigi.zig");
|
||||||
const fmt = @import("format.zig");
|
const fmt = @import("format.zig");
|
||||||
const performance = @import("analytics/performance.zig");
|
const performance = @import("analytics/performance.zig");
|
||||||
const http = @import("net/http.zig");
|
|
||||||
|
|
||||||
pub const DataError = error{
|
pub const DataError = error{
|
||||||
NoApiKey,
|
NoApiKey,
|
||||||
|
|
@ -163,21 +161,9 @@ pub const DataService = struct {
|
||||||
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| {
|
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 };
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
}
|
|
||||||
|
|
||||||
// Try server sync before hitting providers
|
|
||||||
if (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 };
|
|
||||||
}
|
|
||||||
log.debug("{s}: {s} synced from server but stale, falling through to provider", .{ symbol, @tagName(data_type) });
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("{s}: fetching {s} from provider", .{ symbol, @tagName(data_type) });
|
|
||||||
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
|
||||||
|
|
@ -242,22 +228,10 @@ pub const DataService = struct {
|
||||||
const m = mr.meta;
|
const m = mr.meta;
|
||||||
if (s.isCandleMetaFresh(symbol)) {
|
if (s.isCandleMetaFresh(symbol)) {
|
||||||
// Fresh — deserialize candles and return
|
// Fresh — deserialize candles and return
|
||||||
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 };
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale — try server sync before incremental fetch
|
|
||||||
if (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|
|
|
||||||
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
|
|
||||||
}
|
|
||||||
log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol});
|
|
||||||
// Server data also stale — fall through to incremental fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale — try incremental update using last_date from meta
|
// Stale — try incremental update using last_date from meta
|
||||||
const fetch_from = m.last_date.addDays(1);
|
const fetch_from = m.last_date.addDays(1);
|
||||||
|
|
||||||
|
|
@ -309,19 +283,7 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable cache — try server sync first
|
|
||||||
if (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|
|
|
||||||
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
|
|
||||||
}
|
|
||||||
log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol});
|
|
||||||
// Server data also stale — fall through to full fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
// No usable cache — full fetch (~10 years, plus buffer for leap years)
|
// No usable cache — full fetch (~10 years, plus buffer for leap years)
|
||||||
log.debug("{s}: fetching full candle history from provider", .{symbol});
|
|
||||||
var td = try self.getProvider(TwelveData);
|
var td = try self.getProvider(TwelveData);
|
||||||
const from = today.addDays(-3700);
|
const from = today.addDays(-3700);
|
||||||
|
|
||||||
|
|
@ -379,23 +341,12 @@ pub const DataService = struct {
|
||||||
} else false;
|
} else false;
|
||||||
|
|
||||||
if (!needs_refresh) {
|
if (!needs_refresh) {
|
||||||
log.debug("{s}: earnings fresh in local cache", .{symbol});
|
|
||||||
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
}
|
}
|
||||||
// Stale: free cached events and re-fetch below
|
// Stale: free cached events and re-fetch below
|
||||||
self.allocator.free(cached.data);
|
self.allocator.free(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try server sync before hitting Finnhub
|
|
||||||
if (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 };
|
|
||||||
}
|
|
||||||
log.debug("{s}: earnings synced from server but stale, falling through to provider", .{symbol});
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("{s}: fetching earnings from provider", .{symbol});
|
|
||||||
var fh = try self.getProvider(Finnhub);
|
var fh = try self.getProvider(Finnhub);
|
||||||
const from = today.subtractYears(5);
|
const from = today.subtractYears(5);
|
||||||
const to = today.addDays(365);
|
const to = today.addDays(365);
|
||||||
|
|
@ -770,55 +721,6 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server sync ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Try to sync a cache file from the configured zfin-server.
|
|
||||||
/// Returns true if the file was successfully synced, false on any error.
|
|
||||||
/// Silently returns false if no server is configured.
|
|
||||||
fn syncFromServer(self: *DataService, symbol: []const u8, data_type: cache.DataType) bool {
|
|
||||||
const server_url = self.config.server_url orelse return false;
|
|
||||||
const endpoint = switch (data_type) {
|
|
||||||
.candles_daily => "/candles",
|
|
||||||
.candles_meta => "/candles_meta",
|
|
||||||
.dividends => "/dividends",
|
|
||||||
.earnings => "/earnings",
|
|
||||||
.options => "/options",
|
|
||||||
.splits => return false, // not served
|
|
||||||
.etf_profile => return false, // not served
|
|
||||||
.meta => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const full_url = std.fmt.allocPrint(self.allocator, "{s}/{s}{s}", .{ server_url, symbol, endpoint }) catch return false;
|
|
||||||
defer self.allocator.free(full_url);
|
|
||||||
|
|
||||||
log.debug("{s}: syncing {s} from server", .{ symbol, @tagName(data_type) });
|
|
||||||
|
|
||||||
var client = http.Client.init(self.allocator);
|
|
||||||
defer client.deinit();
|
|
||||||
|
|
||||||
var response = client.get(full_url) catch |err| {
|
|
||||||
log.debug("{s}: server sync failed for {s}: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
defer response.deinit();
|
|
||||||
|
|
||||||
// Write to local cache
|
|
||||||
var s = self.store();
|
|
||||||
s.writeRaw(symbol, data_type, response.body) catch |err| {
|
|
||||||
log.debug("{s}: failed to write synced {s} to cache: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
log.debug("{s}: synced {s} from server ({d} bytes)", .{ symbol, @tagName(data_type), response.body.len });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync candle data (both daily and meta) from the server.
|
|
||||||
fn syncCandlesFromServer(self: *DataService, symbol: []const u8) bool {
|
|
||||||
const daily = self.syncFromServer(symbol, .candles_daily);
|
|
||||||
const meta = self.syncFromServer(symbol, .candles_meta);
|
|
||||||
return daily and meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
|
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
|
||||||
/// These don't have quarterly earnings on Finnhub.
|
/// These don't have quarterly earnings on Finnhub.
|
||||||
fn isMutualFund(symbol: []const u8) bool {
|
fn isMutualFund(symbol: []const u8) bool {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue