Files
sure/app/models/provider/tiingo.rb
soky srm 7908f7d8a4 Expand financial providers (#1407)
* Initial implementation

* Tiingo fixes

* Adds 2 providers, remove 2

* Add  extra checks

* FIX a big hotwire race condition

// Fix hotwire_combobox race condition: when typing quickly, a slow response for
// an early query (e.g. "A") can overwrite the correct results for the final query
// (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires,
// so stale Turbo Stream responses never reach the DOM.

* pipelock

* Update price_test.rb

* Reviews

* i8n

* fixes

* fixes

* Update tiingo.rb

* fixes

* Improvements

* Big revamp

* optimisations

* Update 20260408151837_add_offline_reason_to_securities.rb

* Add missing tests, fixes

* small rank tests

* FIX tests

* Update show.html.erb

* Update resolver.rb

* Update usd_converter.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update _yahoo_finance_settings.html.erb
2026-04-09 18:33:59 +02:00

295 lines
9.4 KiB
Ruby

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