implement Tiingo IEX

This commit is contained in:
Emil Lerch 2026-06-29 21:17:35 -07:00
parent a73966ad39
commit 0f09c6d31a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 339 additions and 25 deletions

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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`.