mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 19:44:09 +00:00
* 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
295 lines
9.4 KiB
Ruby
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
|