class Provider::Tiingo < Provider include SecurityConcept, RateLimitable extend SslConfigurable # Subclass so errors caught in this provider are raised as Provider::Tiingo::Error Error = Class.new(Provider::Error) InvalidSecurityPriceError = Class.new(Error) RateLimitError = Class.new(Error) # Minimum delay between requests to avoid rate limiting (in seconds) MIN_REQUEST_INTERVAL = 1.5 # Maximum unique symbols per month (Tiingo free tier limit) MAX_SYMBOLS_PER_MONTH = 500 # Maximum requests per hour MAX_REQUESTS_PER_HOUR = 1000 # Tiingo exchange names to MIC codes TIINGO_EXCHANGE_TO_MIC = { "NASDAQ" => "XNAS", "NYSE" => "XNYS", "NYSE ARCA" => "XARC", "NYSE MKT" => "XASE", "BATS" => "BATS", "LSE" => "XLON", "SHE" => "XSHE", "SHG" => "XSHG", "OTCMKTS" => "XOTC", "OTCD" => "XOTC", "PINK" => "XOTC" }.freeze # Tiingo asset types to normalized kinds TIINGO_ASSET_TYPE_MAP = { "Stock" => "common stock", "ETF" => "etf", "Mutual Fund" => "mutual fund" }.freeze def initialize(api_key) @api_key = api_key # pipelock:ignore end def healthy? with_provider_response do response = client.get("#{base_url}/tiingo/daily/AAPL") parsed = JSON.parse(response.body) parsed.dig("ticker").present? end end def usage with_provider_response do count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" symbols_used = Rails.cache.read(count_key).to_i UsageData.new( used: symbols_used, limit: MAX_SYMBOLS_PER_MONTH, utilization: (symbols_used.to_f / MAX_SYMBOLS_PER_MONTH * 100).round(1), plan: "Free" ) end end # ================================ # Securities # ================================ def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) with_provider_response do throttle_request response = client.get("#{base_url}/tiingo/utilities/search") do |req| req.params["query"] = symbol end parsed = JSON.parse(response.body) check_api_error!(parsed) unless parsed.is_a?(Array) raise Error, "Unexpected response format from search endpoint" end parsed.first(25).map do |security| ticker = security["ticker"] currency = security["priceCurrency"] # Cache the API-returned currency so fetch_security_prices can use it # without making a second search request if currency.present? && ticker.present? Rails.cache.write("tiingo:currency:#{ticker.upcase}", currency, expires_in: 24.hours) end Security.new( symbol: ticker, name: security["name"], logo_url: nil, exchange_operating_mic: map_exchange_to_mic(security["exchange"]), country_code: security["countryCode"].presence || country_code, currency: currency ) end end end def fetch_security_info(symbol:, exchange_operating_mic:) with_provider_response do throttle_request track_symbol(symbol) response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}") parsed = JSON.parse(response.body) check_api_error!(parsed) # The daily metadata endpoint returns exchangeCode (e.g., "NYSE ARCA", "OTCD") resolved_mic = exchange_operating_mic.presence || map_exchange_to_mic(parsed["exchangeCode"]) SecurityInfo.new( symbol: parsed["ticker"] || symbol, name: parsed["name"], links: nil, logo_url: nil, description: parsed["description"], kind: nil, exchange_operating_mic: resolved_mic ) end end def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) with_provider_response do historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) raise historical_data.error if historical_data.error.present? raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? historical_data.data.first end end def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) with_provider_response do throttle_request track_symbol(symbol) response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}/prices") do |req| req.params["startDate"] = start_date.to_s req.params["endDate"] = end_date.to_s end parsed = JSON.parse(response.body) check_api_error!(parsed) unless parsed.is_a?(Array) error_message = parsed.is_a?(Hash) ? (parsed["detail"] || "Unexpected response format") : "Unexpected response format" raise InvalidSecurityPriceError, "API error: #{error_message}" end # Prefer cached currency from search results to avoid a second API call cache_key = "tiingo:currency:#{symbol.upcase}" currency = Rails.cache.read(cache_key) || fetch_currency_for_symbol(symbol) parsed.map do |resp| price = resp["close"] date = resp["date"] if price.nil? || price.to_f <= 0 Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") next end Price.new( symbol: symbol, date: Date.parse(date), price: price, currency: currency, exchange_operating_mic: exchange_operating_mic ) end.compact end end private attr_reader :api_key def base_url ENV["TIINGO_URL"] || "https://api.tiingo.com" end def client @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| faraday.request(:retry, { max: 3, interval: 1.0, interval_randomness: 0.5, backoff_factor: 2, exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] }) faraday.request :json faraday.response :raise_error faraday.headers["Authorization"] = "Token #{api_key}" faraday.headers["Content-Type"] = "application/json" end end # Adds hourly request counter on top of the interval throttle from RateLimitable. def throttle_request super # Global per-hour request counter via cache (Redis). # Atomic increment-then-check avoids the TOCTOU of read-check-increment. hour_key = "tiingo:requests:#{Time.current.to_i / 3600}" new_count = Rails.cache.increment(hour_key, 1, expires_in: 7200.seconds).to_i if new_count >= max_requests_per_hour raise RateLimitError, "Tiingo hourly request limit reached (#{new_count}/#{max_requests_per_hour})" end end # Tracks unique symbols queried per month to stay within Tiingo's 500 symbols/month limit. # Uses atomic set-if-absent (Redis SETNX) to eliminate the read-then-write race # where two concurrent workers could both see the symbol as untracked and both # increment the counter. def track_symbol(symbol) symbol_key = "tiingo:symbol:#{Date.current.strftime('%Y-%m')}:#{symbol.upcase}" count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" # Atomic write-if-absent: returns false when the key already exists (Redis SETNX). # Only the first worker to claim this symbol will proceed to increment the counter. return unless Rails.cache.write(symbol_key, true, expires_in: 35.days, unless_exist: true) new_count = Rails.cache.increment(count_key, 1, expires_in: 35.days).to_i if new_count >= MAX_SYMBOLS_PER_MONTH Rails.cache.decrement(count_key, 1) Rails.cache.delete(symbol_key) raise RateLimitError, "Tiingo unique symbol limit reached (#{MAX_SYMBOLS_PER_MONTH} per month)" end end # min_request_interval provided by RateLimitable def max_requests_per_hour ENV.fetch("TIINGO_MAX_REQUESTS_PER_HOUR", MAX_REQUESTS_PER_HOUR).to_i end # Fetches the price currency for a symbol via the search endpoint. # Only called as a fallback when the cache (populated by search_securities) # doesn't have the currency. Raises on failure to avoid silently mislabeling # non-USD instruments as USD. def fetch_currency_for_symbol(symbol) throttle_request response = client.get("#{base_url}/tiingo/utilities/search") do |req| req.params["query"] = symbol end parsed = JSON.parse(response.body) check_api_error!(parsed) if parsed.is_a?(Array) match = parsed.find { |s| s["ticker"]&.upcase == symbol.upcase } currency = match&.dig("priceCurrency") if currency.present? Rails.cache.write("tiingo:currency:#{symbol.upcase}", currency, expires_in: 24.hours) return currency end end raise Error, "Could not determine currency for #{symbol} from Tiingo search" end def map_exchange_to_mic(exchange_name) return nil if exchange_name.blank? TIINGO_EXCHANGE_TO_MIC[exchange_name.strip] || exchange_name.strip end def check_api_error!(parsed) return unless parsed.is_a?(Hash) && parsed["detail"].present? detail = parsed["detail"] if detail.downcase.include?("rate limit") || detail.downcase.include?("too many") raise RateLimitError, detail end raise Error, "API error: #{detail}" end end