mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 11:34:13 +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
305 lines
9.2 KiB
Ruby
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
|