Files
sure/app/models/provider/eodhd.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

305 lines
9.2 KiB
Ruby

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