From 0f09c6d31af85b3fc86a15e699088b478b164b61 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 29 Jun 2026 21:17:35 -0700 Subject: [PATCH] implement Tiingo IEX --- src/Config.zig | 68 ++++++++++++++++ src/PortfolioData.zig | 29 +++++++ src/format.zig | 26 ++++++ src/providers/Wikidata.zig | 9 +-- src/providers/tiingo.zig | 162 +++++++++++++++++++++++++++++++++++++ src/service.zig | 70 ++++++++++++---- 6 files changed, 339 insertions(+), 25 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index c6399a4..7c5ce47 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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; diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index b9dcc35..da5b1e2 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -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)); +} diff --git a/src/format.zig b/src/format.zig index 30b7984..cc094f0 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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); diff --git a/src/providers/Wikidata.zig b/src/providers/Wikidata.zig index 67c5381..b6d62df 100644 --- a/src/providers/Wikidata.zig +++ b/src/providers/Wikidata.zig @@ -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) { diff --git a/src/providers/tiingo.zig b/src/providers/tiingo.zig index efcd618..2cac4ee 100644 --- a/src/providers/tiingo.zig +++ b/src/providers/tiingo.zig @@ -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//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); +} diff --git a/src/service.zig b/src/service.zig index bec5984..8f9884c 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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`.