implement Tiingo IEX
This commit is contained in:
parent
a73966ad39
commit
0f09c6d31a
6 changed files with 339 additions and 25 deletions
|
|
@ -37,6 +37,13 @@ pub const default_watchlist_filename = "watchlist.srf";
|
|||
/// ceiling substantially. Defaults to `.free`.
|
||||
pub const TiingoPlan = enum { free, power };
|
||||
|
||||
/// Which provider serves live intraday quotes (the TUI `r` refresh and
|
||||
/// the streaming snapshot). `.yahoo` is keyless/free/consolidated but
|
||||
/// ~15-min delayed and unofficial; `.tiingo` is real-time IEX last-sale
|
||||
/// but needs a key and bills per request. Resolved by
|
||||
/// `effectiveLiveQuoteProvider`.
|
||||
pub const LiveQuoteProvider = enum { yahoo, tiingo };
|
||||
|
||||
// ── Fields ───────────────────────────────────────────────────
|
||||
|
||||
twelvedata_key: ?[]const u8 = null,
|
||||
|
|
@ -46,6 +53,9 @@ tiingo_key: ?[]const u8 = null,
|
|||
/// Tiingo subscription tier (`ZFIN_TIINGO_PLAN`). Drives the provider's
|
||||
/// hourly rate-limit budget via `tiingoHourlyLimit`. Defaults to `.free`.
|
||||
tiingo_plan: TiingoPlan = .free,
|
||||
/// Explicit live-quote provider override (`ZFIN_LIVE_QUOTE_PROVIDER`).
|
||||
/// Null means "derive from the plan" - see `effectiveLiveQuoteProvider`.
|
||||
live_quote_provider_override: ?LiveQuoteProvider = null,
|
||||
openfigi_key: ?[]const u8 = null,
|
||||
/// User contact email used as the User-Agent / From header for
|
||||
/// open-data providers that require politeness identification
|
||||
|
|
@ -112,6 +122,7 @@ pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std
|
|||
self.fmp_key = self.resolve("FMP_API_KEY");
|
||||
self.tiingo_key = self.resolve("TIINGO_API_KEY");
|
||||
self.tiingo_plan = parseTiingoPlan(self.resolve("ZFIN_TIINGO_PLAN"));
|
||||
self.live_quote_provider_override = parseLiveQuoteProvider(self.resolve("ZFIN_LIVE_QUOTE_PROVIDER"));
|
||||
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
|
||||
self.user_email = self.resolve("ZFIN_USER_EMAIL");
|
||||
self.server_url = self.resolve("ZFIN_SERVER");
|
||||
|
|
@ -403,6 +414,19 @@ pub fn tiingoHourlyLimit(self: @This()) usize {
|
|||
};
|
||||
}
|
||||
|
||||
/// Resolve the live-quote provider: an explicit `ZFIN_LIVE_QUOTE_PROVIDER`
|
||||
/// override wins; otherwise the Power plan defaults to Tiingo (real-time
|
||||
/// IEX) and everyone else to Yahoo. Tiingo requires `TIINGO_API_KEY`;
|
||||
/// without it the result falls back to Yahoo so it never promises a path
|
||||
/// that can't run. Yahoo stays the safe default - keyless, on no
|
||||
/// rate-limit budget.
|
||||
pub fn effectiveLiveQuoteProvider(self: @This()) LiveQuoteProvider {
|
||||
const want = self.live_quote_provider_override orelse
|
||||
(if (self.tiingo_plan == .power) LiveQuoteProvider.tiingo else LiveQuoteProvider.yahoo);
|
||||
if (want == .tiingo and self.tiingo_key == null) return .yahoo;
|
||||
return want;
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────
|
||||
|
||||
/// Look up a key: process environment first, then .env file fallback.
|
||||
|
|
@ -428,6 +452,18 @@ fn parseTiingoPlan(value: ?[]const u8) TiingoPlan {
|
|||
return .free;
|
||||
}
|
||||
|
||||
/// Parse `ZFIN_LIVE_QUOTE_PROVIDER` into a `LiveQuoteProvider` override.
|
||||
/// Absent -> null (derive from the plan). A non-empty unrecognized value
|
||||
/// warns (gated under `!builtin.is_test`) and returns null so a typo
|
||||
/// falls back to the plan default rather than silently picking a feed.
|
||||
fn parseLiveQuoteProvider(value: ?[]const u8) ?LiveQuoteProvider {
|
||||
const v = value orelse return null;
|
||||
if (std.ascii.eqlIgnoreCase(v, "yahoo")) return .yahoo;
|
||||
if (std.ascii.eqlIgnoreCase(v, "tiingo")) return .tiingo;
|
||||
if (!builtin.is_test) std.log.scoped(.config).warn("ZFIN_LIVE_QUOTE_PROVIDER=\"{s}\" not recognized (expected yahoo|tiingo); using plan default", .{v});
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parse all KEY=VALUE pairs from .env content into a HashMap.
|
||||
/// Values are slices into the original buffer (no extra allocations per entry).
|
||||
fn parseEnvFile(allocator: std.mem.Allocator, data: []const u8) ?EnvMap {
|
||||
|
|
@ -574,6 +610,38 @@ test "tiingoHourlyLimit: free is 50/hr, power is 10,000/hr" {
|
|||
try testing.expectEqual(@as(usize, 10_000), c.tiingoHourlyLimit());
|
||||
}
|
||||
|
||||
test "parseLiveQuoteProvider: recognized (case-insensitive), else null" {
|
||||
try testing.expectEqual(LiveQuoteProvider.yahoo, parseLiveQuoteProvider("yahoo").?);
|
||||
try testing.expectEqual(LiveQuoteProvider.tiingo, parseLiveQuoteProvider("Tiingo").?);
|
||||
try testing.expect(parseLiveQuoteProvider(null) == null);
|
||||
try testing.expect(parseLiveQuoteProvider("bogus") == null);
|
||||
}
|
||||
|
||||
test "effectiveLiveQuoteProvider: plan default, override, and key guard" {
|
||||
// Free plan, no override -> Yahoo.
|
||||
var c: @This() = .{ .cache_dir = "/tmp" };
|
||||
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
|
||||
|
||||
// Power plan + key -> Tiingo by default.
|
||||
c.tiingo_plan = .power;
|
||||
c.tiingo_key = "k";
|
||||
try testing.expectEqual(LiveQuoteProvider.tiingo, c.effectiveLiveQuoteProvider());
|
||||
|
||||
// Power plan but no key -> falls back to Yahoo (never promise a path
|
||||
// that can't run).
|
||||
c.tiingo_key = null;
|
||||
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
|
||||
|
||||
// Explicit override wins over the plan default - but still
|
||||
// key-guarded for Tiingo.
|
||||
c.tiingo_plan = .free;
|
||||
c.tiingo_key = "k";
|
||||
c.live_quote_provider_override = .tiingo;
|
||||
try testing.expectEqual(LiveQuoteProvider.tiingo, c.effectiveLiveQuoteProvider());
|
||||
c.tiingo_key = null;
|
||||
try testing.expectEqual(LiveQuoteProvider.yahoo, c.effectiveLiveQuoteProvider());
|
||||
}
|
||||
|
||||
test "ResolvedPath.deinit: frees when owned, no-op when not owned" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
|
|
|
|||
|
|
@ -1593,3 +1593,32 @@ test "PortfolioData.revalue: no-op before any load" {
|
|||
try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
|
||||
try testing.expect(pd.summary == null);
|
||||
}
|
||||
|
||||
test "PortfolioData.revalue: returns false when the recompute yields no allocations" {
|
||||
var svc: DataService = .{
|
||||
.allocator = testing.allocator,
|
||||
.io = testing.io,
|
||||
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
|
||||
};
|
||||
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
|
||||
defer {
|
||||
pd.file = null;
|
||||
pd.deinit();
|
||||
}
|
||||
|
||||
// A single zero-share position: portfolioSummary skips shares <= 0,
|
||||
// so the summary has no allocations and revalue reports failure
|
||||
// (leaving any prior summary untouched).
|
||||
var positions = [_]zfin.Position{
|
||||
.{ .symbol = "ZERO", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 0, .realized_gain_loss = 0 },
|
||||
};
|
||||
pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
|
||||
pd.revalue_positions = &positions;
|
||||
var base = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer base.deinit();
|
||||
pd.revalue_base_prices = base;
|
||||
|
||||
var overlay = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer overlay.deinit();
|
||||
try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -592,6 +592,20 @@ pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
|
|||
return buf[0..len];
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup: return the first element of `haystack` equal
|
||||
/// to `needle` ignoring ASCII case, or null. Returns the HAYSTACK's own
|
||||
/// slice (not `needle`), so callers can use the result as a stable key
|
||||
/// when `needle` is borrowed/transient - e.g. mapping a provider's
|
||||
/// echoed-back ticker to the caller's symbol slice, or verifying a
|
||||
/// returned ticker is one that was requested. O(n) linear scan, intended
|
||||
/// for small lists (symbol sets: tens to hundreds).
|
||||
pub fn findIgnoreCase(haystack: []const []const u8, needle: []const u8) ?[]const u8 {
|
||||
for (haystack) |item| {
|
||||
if (std.ascii.eqlIgnoreCase(item, needle)) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
|
||||
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
|
||||
var last_buf: [12]u8 = undefined;
|
||||
|
|
@ -927,6 +941,18 @@ test "fmtIntCommas" {
|
|||
try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567));
|
||||
}
|
||||
|
||||
test "findIgnoreCase: returns the haystack slice, case-insensitive" {
|
||||
const haystack = [_][]const u8{ "aapl", "Spy", "VTI" };
|
||||
// Returns the haystack's own slice (a stable key), not the needle.
|
||||
try std.testing.expectEqualStrings("aapl", findIgnoreCase(&haystack, "AAPL").?);
|
||||
try std.testing.expectEqualStrings("Spy", findIgnoreCase(&haystack, "SPY").?);
|
||||
try std.testing.expectEqualStrings("VTI", findIgnoreCase(&haystack, "vti").?);
|
||||
// Not present -> null (membership test = `!= null`).
|
||||
try std.testing.expect(findIgnoreCase(&haystack, "TSLA") == null);
|
||||
// Empty haystack -> null.
|
||||
try std.testing.expect(findIgnoreCase(&.{}, "AAPL") == null);
|
||||
}
|
||||
|
||||
test "fmtLargeNum" {
|
||||
// Sub-million: formatted as raw number
|
||||
const small = fmtLargeNum(12345.0);
|
||||
|
|
|
|||
|
|
@ -377,14 +377,7 @@ fn parse(
|
|||
|
||||
// Verify ticker is one we asked for. Wikidata can return
|
||||
// surprising matches (foreign exchanges); skip those.
|
||||
var matched = false;
|
||||
for (expected_symbols) |s| {
|
||||
if (std.ascii.eqlIgnoreCase(s, ticker)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) continue;
|
||||
if (fmt.findIgnoreCase(expected_symbols, ticker) == null) continue;
|
||||
|
||||
const existing_or_new = try by_symbol.getOrPut(ticker);
|
||||
if (!existing_or_new.found_existing) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const optFloat = json_utils.optFloat;
|
|||
const jsonStr = json_utils.jsonStr;
|
||||
|
||||
const base_url = "https://api.tiingo.com/tiingo/daily";
|
||||
const iex_url = "https://api.tiingo.com/iex";
|
||||
|
||||
/// Combined fetch result: candles, dividends, and splits parsed from
|
||||
/// a single `/daily/<sym>/prices` response. Caller owns all three
|
||||
|
|
@ -72,6 +73,25 @@ pub const CandleAndCorporateActions = struct {
|
|||
splits: []Split,
|
||||
};
|
||||
|
||||
/// One real-time quote row from Tiingo's `/iex` batch endpoint. The
|
||||
/// ticker is stored inline so the value owns no heap memory - a
|
||||
/// `[]IexQuote` slice is freed with a single `allocator.free`.
|
||||
pub const IexQuote = struct {
|
||||
/// IEX trades equities/ETFs only; tickers are short. 16 bytes
|
||||
/// covers symbols plus class suffixes.
|
||||
ticker_buf: [16]u8 = @splat(0),
|
||||
ticker_len: usize = 0,
|
||||
/// IEX-derived reference price (Tiingo `tngoLast`). Null when IEX
|
||||
/// doesn't trade the symbol (mutual funds) or has no print yet.
|
||||
tngo_last: ?f64 = null,
|
||||
/// Prior session close (`prevClose`), for day-change display.
|
||||
prev_close: ?f64 = null,
|
||||
|
||||
pub fn ticker(self: *const IexQuote) []const u8 {
|
||||
return self.ticker_buf[0..self.ticker_len];
|
||||
}
|
||||
};
|
||||
|
||||
pub const Tiingo = struct {
|
||||
client: http.Client,
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -186,6 +206,36 @@ pub const Tiingo = struct {
|
|||
Dividend.freeSlice(allocator, triple.dividends);
|
||||
return triple.splits;
|
||||
}
|
||||
|
||||
/// Fetch real-time IEX quotes for a batch of `tickers` in ONE HTTP
|
||||
/// request (Tiingo bills the whole batch as a single call against
|
||||
/// the hourly quota). Returns one `IexQuote` per row Tiingo sends,
|
||||
/// keyed by the returned `ticker` - the response order is NOT
|
||||
/// guaranteed to match the request, so callers match by ticker.
|
||||
/// Symbols IEX can't price (mutual funds) come back with a null
|
||||
/// `tngo_last`. Caller owns the slice (`allocator.free`).
|
||||
pub fn fetchQuotes(self: *Tiingo, allocator: std.mem.Allocator, tickers: []const []const u8) ![]IexQuote {
|
||||
var ticker_csv: std.ArrayList(u8) = .empty;
|
||||
defer ticker_csv.deinit(allocator);
|
||||
for (tickers, 0..) |t, i| {
|
||||
if (i != 0) try ticker_csv.append(allocator, ',');
|
||||
try ticker_csv.appendSlice(allocator, t);
|
||||
}
|
||||
|
||||
const url = try http.buildUrl(allocator, iex_url, &.{
|
||||
.{ "tickers", ticker_csv.items },
|
||||
.{ "token", self.api_key },
|
||||
});
|
||||
defer allocator.free(url);
|
||||
|
||||
// One batched call = one request against the hourly bucket.
|
||||
self.rate_limiter.acquire();
|
||||
|
||||
var response = try self.client.get(url);
|
||||
defer response.deinit();
|
||||
|
||||
return parseQuotes(allocator, response.body);
|
||||
}
|
||||
};
|
||||
|
||||
/// Walk Tiingo's JSON array of price rows once, emitting candles,
|
||||
|
|
@ -276,6 +326,43 @@ fn parseDate(val: ?std.json.Value) ?Date {
|
|||
return Date.parse(s[0..10]) catch null;
|
||||
}
|
||||
|
||||
/// Parse Tiingo's `/iex` batch response: a JSON array of per-ticker
|
||||
/// quote objects. Extracts `ticker`, `tngoLast`, and `prevClose`; the
|
||||
/// ticker is copied inline (truncated to capacity). Pure and
|
||||
/// allocator-scoped, so it's fully fixture-testable. A non-array root
|
||||
/// (Tiingo errors return an object with `detail`) is `RequestFailed`.
|
||||
fn parseQuotes(allocator: std.mem.Allocator, body: []const u8) ![]IexQuote {
|
||||
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
|
||||
return error.ParseError;
|
||||
defer parsed.deinit();
|
||||
|
||||
const items = switch (parsed.value) {
|
||||
.array => |a| a.items,
|
||||
else => return error.RequestFailed,
|
||||
};
|
||||
|
||||
var quotes: std.ArrayList(IexQuote) = .empty;
|
||||
errdefer quotes.deinit(allocator);
|
||||
|
||||
for (items) |item| {
|
||||
const obj = switch (item) {
|
||||
.object => |o| o,
|
||||
else => continue,
|
||||
};
|
||||
const tkr = jsonStr(obj.get("ticker")) orelse continue;
|
||||
var q: IexQuote = .{
|
||||
.tngo_last = optFloat(obj.get("tngoLast")),
|
||||
.prev_close = optFloat(obj.get("prevClose")),
|
||||
};
|
||||
const n = @min(tkr.len, q.ticker_buf.len);
|
||||
@memcpy(q.ticker_buf[0..n], tkr[0..n]);
|
||||
q.ticker_len = n;
|
||||
try quotes.append(allocator, q);
|
||||
}
|
||||
|
||||
return quotes.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
test "parseAll basic candles, no events" {
|
||||
|
|
@ -483,3 +570,78 @@ test "parseAll empty array" {
|
|||
try std.testing.expectEqual(@as(usize, 0), triple.dividends.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), triple.splits.len);
|
||||
}
|
||||
|
||||
test "parseQuotes: batch of IEX quotes (ticker + tngoLast + prevClose)" {
|
||||
const body =
|
||||
\\[
|
||||
\\ {"ticker":"AAPL","tngoLast":175.25,"prevClose":172.00,"last":175.25,"open":174.0},
|
||||
\\ {"ticker":"SPY","tngoLast":612.50,"prevClose":610.00}
|
||||
\\]
|
||||
;
|
||||
const a = std.testing.allocator;
|
||||
const quotes = try parseQuotes(a, body);
|
||||
defer a.free(quotes);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), quotes.len);
|
||||
try std.testing.expectEqualStrings("AAPL", quotes[0].ticker());
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 175.25), quotes[0].tngo_last.?, 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 172.00), quotes[0].prev_close.?, 0.001);
|
||||
try std.testing.expectEqualStrings("SPY", quotes[1].ticker());
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 612.50), quotes[1].tngo_last.?, 0.001);
|
||||
}
|
||||
|
||||
test "parseQuotes: null tngoLast (a symbol IEX doesn't trade)" {
|
||||
const body =
|
||||
\\[{"ticker":"VTSAX","tngoLast":null,"prevClose":110.0}]
|
||||
;
|
||||
const a = std.testing.allocator;
|
||||
const quotes = try parseQuotes(a, body);
|
||||
defer a.free(quotes);
|
||||
try std.testing.expectEqual(@as(usize, 1), quotes.len);
|
||||
try std.testing.expect(quotes[0].tngo_last == null);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 110.0), quotes[0].prev_close.?, 0.001);
|
||||
}
|
||||
|
||||
test "parseQuotes: error object (non-array root) -> RequestFailed" {
|
||||
const a = std.testing.allocator;
|
||||
try std.testing.expectError(error.RequestFailed, parseQuotes(a,
|
||||
\\{"detail":"Not authorized."}
|
||||
));
|
||||
}
|
||||
|
||||
test "parseQuotes: malformed JSON -> ParseError" {
|
||||
const a = std.testing.allocator;
|
||||
try std.testing.expectError(error.ParseError, parseQuotes(a, "not json"));
|
||||
}
|
||||
|
||||
test "parseQuotes: empty array" {
|
||||
const a = std.testing.allocator;
|
||||
const quotes = try parseQuotes(a, "[]");
|
||||
defer a.free(quotes);
|
||||
try std.testing.expectEqual(@as(usize, 0), quotes.len);
|
||||
}
|
||||
|
||||
test "parseQuotes: over-long ticker is truncated to capacity" {
|
||||
const body =
|
||||
\\[{"ticker":"ABCDEFGHIJKLMNOPQRSTUVWXYZ","tngoLast":1.0}]
|
||||
;
|
||||
const a = std.testing.allocator;
|
||||
const quotes = try parseQuotes(a, body);
|
||||
defer a.free(quotes);
|
||||
try std.testing.expectEqual(@as(usize, 16), quotes[0].ticker().len);
|
||||
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", quotes[0].ticker());
|
||||
}
|
||||
|
||||
test "parseQuotes: skips ticker-less rows and non-object array elements" {
|
||||
// A row missing "ticker" and a stray non-object element are both
|
||||
// skipped; only the well-formed row survives.
|
||||
const body =
|
||||
\\[{"tngoLast":5.0}, 42, {"ticker":"AAPL","tngoLast":1.0}]
|
||||
;
|
||||
const a = std.testing.allocator;
|
||||
const quotes = try parseQuotes(a, body);
|
||||
defer a.free(quotes);
|
||||
try std.testing.expectEqual(@as(usize, 1), quotes.len);
|
||||
try std.testing.expectEqualStrings("AAPL", quotes[0].ticker());
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), quotes[0].tngo_last.?, 0.001);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2316,10 +2316,10 @@ pub const DataService = struct {
|
|||
return result;
|
||||
}
|
||||
|
||||
/// Fetch live intraday quotes for `symbols` in parallel, returning
|
||||
/// a map of symbol -> live last price. Symbols whose quote fetch
|
||||
/// fails (or that the provider can't price) are simply absent; the
|
||||
/// caller falls back to the last cached close.
|
||||
/// Fetch live intraday quotes for `symbols`, returning a map of
|
||||
/// symbol -> live last price. Symbols whose quote fetch fails (or
|
||||
/// that the provider can't price) are simply absent; the caller
|
||||
/// falls back to the last cached close.
|
||||
///
|
||||
/// This is a pure live-price fetch: quotes are never cached, so it
|
||||
/// neither reads nor writes the candle cache. It exists for the
|
||||
|
|
@ -2327,17 +2327,12 @@ pub const DataService = struct {
|
|||
/// distinct from candle-history maintenance (TTL/startup) and from
|
||||
/// `--refresh-data=force` (incremental candle top-up).
|
||||
///
|
||||
/// Unlike `getQuote` (single-symbol, Yahoo->TwelveData fallback),
|
||||
/// this is Yahoo-only: Yahoo is keyless with no shared rate
|
||||
/// limiter, so each worker can safely own its HTTP client.
|
||||
/// TwelveData's shared rate limiter makes it unsafe to fan out, and
|
||||
/// its fallback role isn't worth the complexity for a bulk refresh.
|
||||
///
|
||||
/// Concurrency mirrors `parallelServerSync`: one task per symbol in
|
||||
/// a single `std.Io.Group`, each with its own `Yahoo` client (a
|
||||
/// shared `std.http.Client` is not safe across threads - see
|
||||
/// `tryOneSync`). Relies on a thread-safe `allocator`/`io`, the
|
||||
/// same assumption the server-sync fan-out already makes.
|
||||
/// Provider is `Config.effectiveLiveQuoteProvider()`:
|
||||
/// - `.yahoo` (default): keyless, parallel per-symbol fan-out.
|
||||
/// - `.tiingo`: a single batched `/iex` request returning Tiingo's
|
||||
/// real-time IEX reference price (`tngoLast`). On ANY failure
|
||||
/// (no key, network, auth, parse) it degrades to the Yahoo path
|
||||
/// so a refresh never hard-fails.
|
||||
///
|
||||
/// The returned map's keys borrow `symbols`: keep `symbols` alive
|
||||
/// while using the map, and `deinit()` the map when done.
|
||||
|
|
@ -2347,8 +2342,27 @@ pub const DataService = struct {
|
|||
|
||||
self.assertNetworkAllowed("loadLiveQuotes");
|
||||
|
||||
switch (self.config.effectiveLiveQuoteProvider()) {
|
||||
.yahoo => self.loadLiveQuotesYahoo(symbols, &prices),
|
||||
.tiingo => self.loadLiveQuotesTiingo(symbols, &prices) catch |err| {
|
||||
log.warn("loadLiveQuotes: Tiingo path failed ({t}); falling back to Yahoo", .{err});
|
||||
// Drop any partial fill so we never mix providers.
|
||||
prices.clearRetainingCapacity();
|
||||
self.loadLiveQuotesYahoo(symbols, &prices);
|
||||
},
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
/// Yahoo live-quote fan-out: one task per symbol in a single
|
||||
/// `std.Io.Group`, each with its own `Yahoo` client (a shared
|
||||
/// `std.http.Client` is not safe across threads - see `tryOneSync`).
|
||||
/// Yahoo is keyless with no shared rate limiter, so per-worker
|
||||
/// clients are safe. Relies on a thread-safe `allocator`/`io`, the
|
||||
/// same assumption the server-sync fan-out makes.
|
||||
fn loadLiveQuotesYahoo(self: *DataService, symbols: []const []const u8, prices: *std.StringHashMap(f64)) void {
|
||||
const QuoteSlot = struct { symbol: []const u8, price: ?f64 = null };
|
||||
const slots = self.allocator.alloc(QuoteSlot, symbols.len) catch return prices;
|
||||
const slots = self.allocator.alloc(QuoteSlot, symbols.len) catch return;
|
||||
defer self.allocator.free(slots);
|
||||
for (slots, 0..) |*slot, i| slot.* = .{ .symbol = symbols[i] };
|
||||
|
||||
|
|
@ -2370,7 +2384,29 @@ pub const DataService = struct {
|
|||
for (slots) |slot| {
|
||||
if (slot.price) |p| prices.put(slot.symbol, p) catch |err| log.warn("loadLiveQuotes put({s}): {t}", .{ slot.symbol, err });
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
/// Tiingo live-quote path: a single batched `/iex` request for all
|
||||
/// `symbols`, returning the real-time IEX reference price
|
||||
/// (`tngoLast`). Results are keyed back to the caller's `symbols`
|
||||
/// slices (case-insensitive, since Tiingo upper-cases tickers and
|
||||
/// returns them in arbitrary order), so the map's keys still borrow
|
||||
/// `symbols`. Symbols Tiingo can't price (mutual funds) or with a
|
||||
/// null reference price are absent -> candle-close fallback. Errors
|
||||
/// propagate to `loadLiveQuotes`, which degrades to Yahoo.
|
||||
fn loadLiveQuotesTiingo(self: *DataService, symbols: []const []const u8, prices: *std.StringHashMap(f64)) !void {
|
||||
const tg = try self.getProvider(Tiingo);
|
||||
const quotes = try tg.fetchQuotes(self.allocator, symbols);
|
||||
defer self.allocator.free(quotes);
|
||||
|
||||
for (quotes) |q| {
|
||||
const price = q.tngo_last orelse continue;
|
||||
// Map Tiingo's echoed-back (often upper-cased, arbitrary
|
||||
// order) ticker to the caller's own slice so the result
|
||||
// map's keys borrow `symbols` per the contract.
|
||||
const key = fmt.findIgnoreCase(symbols, q.ticker()) orelse continue;
|
||||
prices.put(key, price) catch |err| log.warn("loadLiveQuotes put({s}): {t}", .{ key, err });
|
||||
}
|
||||
}
|
||||
|
||||
/// Parallel server sync via `std.Io.Group`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue