mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 23:25:00 +00:00
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
This commit is contained in:
304
app/models/provider/eodhd.rb
Normal file
304
app/models/provider/eodhd.rb
Normal file
@@ -0,0 +1,304 @@
|
||||
class Provider::Eodhd < Provider
|
||||
include SecurityConcept, RateLimitable
|
||||
extend SslConfigurable
|
||||
|
||||
# Subclass so errors caught in this provider are raised as Provider::Eodhd::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 = 0.5
|
||||
|
||||
# Maximum API calls per day (EODHD free/basic plans are very restrictive)
|
||||
MAX_REQUESTS_PER_DAY = 20
|
||||
|
||||
# EODHD free tier provides ~1 year of EOD data
|
||||
def max_history_days
|
||||
365
|
||||
end
|
||||
|
||||
# EODHD uses {SYMBOL}.{EXCHANGE} ticker format with its own exchange codes
|
||||
MIC_TO_EODHD_EXCHANGE = {
|
||||
"XNYS" => "US", "XNAS" => "US", "XASE" => "US",
|
||||
"XLON" => "LSE",
|
||||
"XETR" => "XETRA",
|
||||
"XTSE" => "TO",
|
||||
"XPAR" => "PA",
|
||||
"XAMS" => "AS",
|
||||
"XSWX" => "SW",
|
||||
"XHKG" => "HK",
|
||||
"XASX" => "AU",
|
||||
"XTKS" => "TSE",
|
||||
"XMIL" => "MI",
|
||||
"XMAD" => "MC",
|
||||
"XOSL" => "OL",
|
||||
"XHEL" => "HE",
|
||||
"XCSE" => "CO",
|
||||
"XSTO" => "ST",
|
||||
"XKRX" => "KS",
|
||||
"XBOM" => "BSE",
|
||||
"XNSE" => "NSE"
|
||||
}.freeze
|
||||
|
||||
EODHD_EXCHANGE_TO_MIC = {
|
||||
"US" => "XNYS", "LSE" => "XLON", "XETRA" => "XETR",
|
||||
"TO" => "XTSE", "PA" => "XPAR", "AS" => "XAMS",
|
||||
"SW" => "XSWX", "HK" => "XHKG", "AU" => "XASX",
|
||||
"TSE" => "XTKS", "MI" => "XMIL", "MC" => "XMAD",
|
||||
"OL" => "XOSL", "HE" => "XHEL", "CO" => "XCSE",
|
||||
"ST" => "XSTO", "KS" => "XKRX", "BSE" => "XBOM",
|
||||
"NSE" => "XNSE"
|
||||
}.freeze
|
||||
|
||||
EODHD_COUNTRY_TO_CODE = {
|
||||
"USA" => "US", "UK" => "GB", "Germany" => "DE", "France" => "FR",
|
||||
"Netherlands" => "NL", "Switzerland" => "CH", "Canada" => "CA",
|
||||
"Japan" => "JP", "Australia" => "AU", "Hong Kong" => "HK",
|
||||
"Italy" => "IT", "Spain" => "ES", "Norway" => "NO",
|
||||
"Finland" => "FI", "Denmark" => "DK", "Sweden" => "SE",
|
||||
"South Korea" => "KR", "India" => "IN"
|
||||
}.freeze
|
||||
|
||||
EXCHANGE_CURRENCY = {
|
||||
"US" => "USD", "LSE" => "GBP", "XETRA" => "EUR", "TO" => "CAD",
|
||||
"PA" => "EUR", "AS" => "EUR", "SW" => "CHF", "HK" => "HKD",
|
||||
"AU" => "AUD", "TSE" => "JPY", "MI" => "EUR", "MC" => "EUR",
|
||||
"OL" => "NOK", "HE" => "EUR", "CO" => "DKK",
|
||||
"ST" => "SEK", "KS" => "KRW", "BSE" => "INR",
|
||||
"NSE" => "INR"
|
||||
}.freeze
|
||||
|
||||
def initialize(api_key)
|
||||
@api_key = api_key # pipelock:ignore
|
||||
end
|
||||
|
||||
def healthy?
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/api/user") do |req|
|
||||
req.params["api_token"] = api_key
|
||||
req.params["fmt"] = "json"
|
||||
end
|
||||
|
||||
JSON.parse(response.body).dig("name").present?
|
||||
end
|
||||
end
|
||||
|
||||
def usage
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/api/user") do |req|
|
||||
req.params["api_token"] = api_key
|
||||
req.params["fmt"] = "json"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
||||
limit = parsed.dig("apiRequests").to_i
|
||||
daily_limit = parsed.dig("dailyRateLimit").to_i
|
||||
|
||||
daily_key = daily_cache_key
|
||||
used = Rails.cache.read(daily_key).to_i
|
||||
|
||||
UsageData.new(
|
||||
used: used,
|
||||
limit: daily_limit > 0 ? daily_limit : MAX_REQUESTS_PER_DAY,
|
||||
utilization: daily_limit > 0 ? (used.to_f / daily_limit * 100) : (used.to_f / MAX_REQUESTS_PER_DAY * 100),
|
||||
plan: parsed.dig("subscriptionType") || "unknown"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Securities
|
||||
# ================================
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
with_provider_response do
|
||||
enforce_daily_limit!
|
||||
throttle_request
|
||||
|
||||
response = client.get("#{base_url}/api/search/#{CGI.escape(symbol)}") do |req|
|
||||
req.params["api_token"] = api_key
|
||||
req.params["fmt"] = "json"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
check_api_error!(parsed)
|
||||
|
||||
unless parsed.is_a?(Array)
|
||||
raise Error, "Unexpected response format from search API"
|
||||
end
|
||||
|
||||
parsed.first(25).map do |security|
|
||||
eodhd_exchange = security.dig("Exchange")
|
||||
mic = EODHD_EXCHANGE_TO_MIC[eodhd_exchange]
|
||||
country = EODHD_COUNTRY_TO_CODE[security.dig("Country")]
|
||||
code = security.dig("Code")
|
||||
currency = security.dig("Currency")
|
||||
|
||||
# Cache the API-returned currency so fetch_security_prices can use it
|
||||
if currency.present? && mic.present?
|
||||
cache_key = "eodhd:currency:#{code.upcase}:#{mic}"
|
||||
Rails.cache.write(cache_key, currency, expires_in: 24.hours)
|
||||
end
|
||||
|
||||
Security.new(
|
||||
symbol: code,
|
||||
name: security.dig("Name"),
|
||||
logo_url: nil,
|
||||
exchange_operating_mic: mic,
|
||||
country_code: country,
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_security_info(symbol:, exchange_operating_mic:)
|
||||
with_provider_response do
|
||||
enforce_daily_limit!
|
||||
throttle_request
|
||||
|
||||
ticker = eodhd_symbol(symbol, exchange_operating_mic)
|
||||
|
||||
response = client.get("#{base_url}/api/fundamentals/#{CGI.escape(ticker)}") do |req|
|
||||
req.params["api_token"] = api_key
|
||||
req.params["fmt"] = "json"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
check_api_error!(parsed)
|
||||
|
||||
general = parsed.dig("General") || {}
|
||||
|
||||
SecurityInfo.new(
|
||||
symbol: symbol,
|
||||
name: general.dig("Name"),
|
||||
links: general.dig("WebURL"),
|
||||
logo_url: general.dig("LogoURL"),
|
||||
description: general.dig("Description"),
|
||||
kind: general.dig("Type"),
|
||||
exchange_operating_mic: exchange_operating_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
|
||||
enforce_daily_limit!
|
||||
throttle_request
|
||||
|
||||
ticker = eodhd_symbol(symbol, exchange_operating_mic)
|
||||
|
||||
response = client.get("#{base_url}/api/eod/#{CGI.escape(ticker)}") do |req|
|
||||
req.params["api_token"] = api_key
|
||||
req.params["fmt"] = "json"
|
||||
req.params["from"] = start_date.to_s
|
||||
req.params["to"] = end_date.to_s
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
check_api_error!(parsed)
|
||||
|
||||
unless parsed.is_a?(Array)
|
||||
raise InvalidSecurityPriceError, "Unexpected response format from EOD API"
|
||||
end
|
||||
|
||||
# Prefer cached currency from search results; fall back to hardcoded map
|
||||
cache_key = "eodhd:currency:#{symbol.upcase}:#{exchange_operating_mic}"
|
||||
eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic]
|
||||
currency = Rails.cache.read(cache_key) || EXCHANGE_CURRENCY[eodhd_exchange]
|
||||
|
||||
parsed.map do |resp|
|
||||
price = resp.dig("close")
|
||||
date = resp.dig("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.to_date,
|
||||
price: price,
|
||||
currency: currency,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :api_key
|
||||
|
||||
def base_url
|
||||
ENV["EODHD_URL"] || "https://eodhd.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
|
||||
end
|
||||
end
|
||||
|
||||
# Builds the EODHD ticker format: {SYMBOL}.{EXCHANGE}
|
||||
def eodhd_symbol(symbol, exchange_operating_mic)
|
||||
eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic] if exchange_operating_mic.present?
|
||||
|
||||
if eodhd_exchange.present?
|
||||
"#{symbol}.#{eodhd_exchange}"
|
||||
elsif exchange_operating_mic.present?
|
||||
"#{symbol}.#{exchange_operating_mic}"
|
||||
else
|
||||
"#{symbol}.US"
|
||||
end
|
||||
end
|
||||
|
||||
# Cache key for tracking daily API usage
|
||||
def daily_cache_key
|
||||
"eodhd:daily:#{Date.current}"
|
||||
end
|
||||
|
||||
# Enforces the daily rate limit. Raises RateLimitError if the limit is exhausted.
|
||||
# Uses atomic increment-then-check to avoid TOCTOU races between concurrent workers.
|
||||
def enforce_daily_limit!
|
||||
new_count = Rails.cache.increment(daily_cache_key, 1, expires_in: 24.hours).to_i
|
||||
|
||||
if new_count > max_requests_per_day
|
||||
raise RateLimitError, "EODHD daily rate limit of #{max_requests_per_day} requests exhausted"
|
||||
end
|
||||
end
|
||||
|
||||
# throttle_request and min_request_interval provided by RateLimitable
|
||||
|
||||
def max_requests_per_day
|
||||
ENV.fetch("EODHD_MAX_REQUESTS_PER_DAY", MAX_REQUESTS_PER_DAY).to_i
|
||||
end
|
||||
|
||||
def check_api_error!(parsed)
|
||||
return unless parsed.is_a?(Hash) && parsed["error"].present?
|
||||
|
||||
raise Error, "API error: #{parsed["error"]}"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user