mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
Some security prices get returned in non-standard minor units (eg. `GBp` for Great British Pence, instead of `GBP` for Great British Pounds), leading to incorrect handling of the returned price data. This commit adds conversion logic for the two currencies I've been able to find examples of this affecting.
630 lines
19 KiB
Ruby
630 lines
19 KiB
Ruby
class Provider::YahooFinance < Provider
|
|
include ExchangeRateConcept, SecurityConcept
|
|
|
|
# Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error
|
|
Error = Class.new(Provider::Error)
|
|
InvalidSecurityPriceError = Class.new(Error)
|
|
RateLimitError = Class.new(Error)
|
|
InvalidSymbolError = Class.new(Error)
|
|
MarketClosedError = Class.new(Error)
|
|
|
|
# Cache duration for repeated requests (5 minutes)
|
|
CACHE_DURATION = 5.minutes
|
|
|
|
# Maximum lookback window for historical data (configurable)
|
|
MAX_LOOKBACK_WINDOW = 10.years
|
|
|
|
def initialize
|
|
# Yahoo Finance doesn't require an API key but we may want to add proxy support later
|
|
@cache_prefix = "yahoo_finance"
|
|
end
|
|
|
|
def healthy?
|
|
begin
|
|
# Test with a known stable ticker (Apple)
|
|
response = client.get("#{base_url}/v8/finance/chart/AAPL") do |req|
|
|
req.params["interval"] = "1d"
|
|
req.params["range"] = "1d"
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
result = data.dig("chart", "result")
|
|
health_status = result.present? && result.any?
|
|
|
|
health_status
|
|
rescue => e
|
|
false
|
|
end
|
|
end
|
|
|
|
def usage
|
|
# Yahoo Finance doesn't expose usage data, so we return a mock structure
|
|
with_provider_response do
|
|
usage_data = UsageData.new(
|
|
used: 0,
|
|
limit: 2000, # Estimated daily limit based on community knowledge
|
|
utilization: 0,
|
|
plan: "Free"
|
|
)
|
|
|
|
usage_data
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Exchange Rates
|
|
# ================================
|
|
|
|
def fetch_exchange_rate(from:, to:, date:)
|
|
with_provider_response do
|
|
# Return 1.0 if same currency
|
|
if from == to
|
|
Rate.new(date: date, from: from, to: to, rate: 1.0)
|
|
else
|
|
cache_key = "exchange_rate_#{from}_#{to}_#{date}"
|
|
if cached_result = get_cached_result(cache_key)
|
|
cached_result
|
|
else
|
|
# For a single date, we'll fetch a range and find the closest match
|
|
end_date = date
|
|
start_date = date - 10.days # Extended range for better coverage
|
|
|
|
rates_response = fetch_exchange_rates(
|
|
from: from,
|
|
to: to,
|
|
start_date: start_date,
|
|
end_date: end_date
|
|
)
|
|
|
|
raise Error, "Failed to fetch exchange rates: #{rates_response.error.message}" unless rates_response.success?
|
|
|
|
rates = rates_response.data
|
|
if rates.length == 1
|
|
rates.first
|
|
else
|
|
# Find the exact date or the closest previous date
|
|
target_rate = rates.find { |r| r.date == date } ||
|
|
rates.select { |r| r.date <= date }.max_by(&:date)
|
|
|
|
raise Error, "No exchange rate found for #{from}/#{to} on or before #{date}" unless target_rate
|
|
|
|
cache_result(cache_key, target_rate)
|
|
target_rate
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
|
with_provider_response do
|
|
validate_date_range!(start_date, end_date)
|
|
# Return 1.0 rates if same currency
|
|
if from == to
|
|
generate_same_currency_rates(from, to, start_date, end_date)
|
|
else
|
|
cache_key = "exchange_rates_#{from}_#{to}_#{start_date}_#{end_date}"
|
|
if cached_result = get_cached_result(cache_key)
|
|
cached_result
|
|
else
|
|
# Try both direct and inverse currency pairs
|
|
rates = fetch_currency_pair_data(from, to, start_date, end_date) ||
|
|
fetch_inverse_currency_pair_data(from, to, start_date, end_date)
|
|
|
|
raise Error, "No chart data found for currency pair #{from}/#{to}" unless rates&.any?
|
|
|
|
cache_result(cache_key, rates)
|
|
rates
|
|
end
|
|
end
|
|
rescue JSON::ParserError => e
|
|
raise Error, "Invalid response format: #{e.message}"
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Securities
|
|
# ================================
|
|
|
|
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
|
with_provider_response do
|
|
cache_key = "search_#{symbol}_#{country_code}_#{exchange_operating_mic}"
|
|
if cached_result = get_cached_result(cache_key)
|
|
return cached_result
|
|
end
|
|
|
|
response = client.get("#{base_url}/v1/finance/search") do |req|
|
|
req.params["q"] = symbol.strip.upcase
|
|
req.params["quotesCount"] = 25
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
quotes = data.dig("quotes") || []
|
|
|
|
securities = quotes.filter_map do |quote|
|
|
Security.new(
|
|
symbol: quote["symbol"],
|
|
name: quote["longname"] || quote["shortname"] || quote["symbol"],
|
|
logo_url: nil, # Yahoo search doesn't provide logos
|
|
exchange_operating_mic: map_exchange_mic(quote["exchange"]),
|
|
country_code: map_country_code(quote["exchDisp"])
|
|
)
|
|
end
|
|
|
|
cache_result(cache_key, securities)
|
|
securities
|
|
rescue JSON::ParserError => e
|
|
raise Error, "Invalid search response format: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def fetch_security_info(symbol:, exchange_operating_mic:)
|
|
with_provider_response do
|
|
# Use quoteSummary endpoint which is more reliable
|
|
response = client.get("#{base_url}/v10/finance/quoteSummary/#{symbol}") do |req|
|
|
req.params["modules"] = "assetProfile,price,quoteType"
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
result = data.dig("quoteSummary", "result", 0)
|
|
|
|
raise Error, "No security info found for #{symbol}" unless result
|
|
|
|
asset_profile = result["assetProfile"] || {}
|
|
price_info = result["price"] || {}
|
|
quote_type = result["quoteType"] || {}
|
|
|
|
security_info = SecurityInfo.new(
|
|
symbol: symbol,
|
|
name: price_info["longName"] || price_info["shortName"] || quote_type["longName"] || quote_type["shortName"],
|
|
links: asset_profile["website"],
|
|
logo_url: nil, # Yahoo doesn't provide reliable logo URLs
|
|
description: asset_profile["longBusinessSummary"],
|
|
kind: map_security_type(quote_type["quoteType"]),
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
|
|
security_info
|
|
rescue JSON::ParserError => e
|
|
raise Error, "Invalid response format: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def fetch_security_price(symbol:, exchange_operating_mic: nil, date:)
|
|
with_provider_response do
|
|
cache_key = "security_price_#{symbol}_#{exchange_operating_mic}_#{date}"
|
|
if cached_result = get_cached_result(cache_key)
|
|
return cached_result
|
|
end
|
|
|
|
# For a single date, we'll fetch a range and find the closest match
|
|
end_date = date
|
|
start_date = date - 10.days # Extended range for better coverage
|
|
|
|
prices_response = fetch_security_prices(
|
|
symbol: symbol,
|
|
exchange_operating_mic: exchange_operating_mic,
|
|
start_date: start_date,
|
|
end_date: end_date
|
|
)
|
|
|
|
raise Error, "Failed to fetch security prices: #{prices_response.error.message}" unless prices_response.success?
|
|
|
|
prices = prices_response.data
|
|
return prices.first if prices.length == 1
|
|
|
|
# Find the exact date or the closest previous date
|
|
target_price = prices.find { |p| p.date == date } ||
|
|
prices.select { |p| p.date <= date }.max_by(&:date)
|
|
|
|
raise Error, "No price found for #{symbol} on or before #{date}" unless target_price
|
|
|
|
cache_result(cache_key, target_price)
|
|
target_price
|
|
end
|
|
end
|
|
|
|
def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:)
|
|
with_provider_response do
|
|
validate_date_params!(start_date, end_date)
|
|
# Convert dates to Unix timestamps using UTC to ensure consistent epoch boundaries across timezones
|
|
period1 = start_date.to_time.utc.to_i
|
|
period2 = end_date.end_of_day.to_time.utc.to_i
|
|
|
|
response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req|
|
|
req.params["period1"] = period1
|
|
req.params["period2"] = period2
|
|
req.params["interval"] = "1d"
|
|
req.params["includeAdjustedClose"] = true
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
chart_data = data.dig("chart", "result", 0)
|
|
|
|
raise Error, "No chart data found for #{symbol}" unless chart_data
|
|
|
|
timestamps = chart_data.dig("timestamp") || []
|
|
quotes = chart_data.dig("indicators", "quote", 0) || {}
|
|
closes = quotes["close"] || []
|
|
|
|
# Get currency from metadata
|
|
raw_currency = chart_data.dig("meta", "currency") || "USD"
|
|
|
|
prices = []
|
|
timestamps.each_with_index do |timestamp, index|
|
|
close_price = closes[index]
|
|
next if close_price.nil? # Skip days with no data (weekends, holidays)
|
|
|
|
# Normalize currency and price to handle minor units
|
|
normalized_currency, normalized_price = normalize_currency_and_price(raw_currency, close_price.to_f)
|
|
|
|
prices << Price.new(
|
|
symbol: symbol,
|
|
date: Time.at(timestamp).to_date,
|
|
price: normalized_price,
|
|
currency: normalized_currency,
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
end
|
|
|
|
sorted_prices = prices.sort_by(&:date)
|
|
sorted_prices
|
|
rescue JSON::ParserError => e
|
|
raise Error, "Invalid response format: #{e.message}"
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def base_url
|
|
ENV["YAHOO_FINANCE_URL"] || "https://query1.finance.yahoo.com"
|
|
end
|
|
|
|
# ================================
|
|
# Currency Normalization
|
|
# ================================
|
|
|
|
# Yahoo Finance sometimes returns currencies in minor units (pence, cents)
|
|
# This is not part of ISO 4217 but is a convention used by financial data providers
|
|
# Mapping of Yahoo Finance minor unit codes to standard currency codes and conversion multipliers
|
|
MINOR_CURRENCY_CONVERSIONS = {
|
|
"GBp" => { currency: "GBP", multiplier: 0.01 }, # British pence to pounds (eg. https://finance.yahoo.com/quote/IITU.L/)
|
|
"ZAc" => { currency: "ZAR", multiplier: 0.01 } # South African cents to rand (eg. https://finance.yahoo.com/quote/JSE.JO)
|
|
}.freeze
|
|
|
|
# Normalizes Yahoo Finance currency codes and prices
|
|
# Returns [currency_code, price] with currency converted to standard ISO code
|
|
# and price converted from minor units to major units if applicable
|
|
def normalize_currency_and_price(currency, price)
|
|
if conversion = MINOR_CURRENCY_CONVERSIONS[currency]
|
|
[ conversion[:currency], price * conversion[:multiplier] ]
|
|
else
|
|
[ currency, price ]
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Validation
|
|
# ================================
|
|
|
|
|
|
def validate_date_range!(start_date, end_date)
|
|
raise Error, "Start date cannot be after end date" if start_date > end_date
|
|
raise Error, "Date range too large (max 5 years)" if end_date > start_date + 5.years
|
|
end
|
|
|
|
def validate_date_params!(start_date, end_date)
|
|
# Validate presence and coerce to dates
|
|
validated_start_date = validate_and_coerce_date!(start_date, "start_date")
|
|
validated_end_date = validate_and_coerce_date!(end_date, "end_date")
|
|
|
|
# Ensure start_date <= end_date
|
|
if validated_start_date > validated_end_date
|
|
error_msg = "Start date (#{validated_start_date}) cannot be after end date (#{validated_end_date})"
|
|
raise ArgumentError, error_msg
|
|
end
|
|
|
|
# Ensure end_date is not in the future
|
|
today = Date.current
|
|
if validated_end_date > today
|
|
error_msg = "End date (#{validated_end_date}) cannot be in the future"
|
|
raise ArgumentError, error_msg
|
|
end
|
|
|
|
# Optional: Enforce max lookback window (configurable via constant)
|
|
max_lookback = MAX_LOOKBACK_WINDOW.ago.to_date
|
|
if validated_start_date < max_lookback
|
|
error_msg = "Start date (#{validated_start_date}) exceeds maximum lookback window (#{max_lookback})"
|
|
raise ArgumentError, error_msg
|
|
end
|
|
end
|
|
|
|
def validate_and_coerce_date!(date_param, param_name)
|
|
# Check presence
|
|
if date_param.blank?
|
|
error_msg = "#{param_name} cannot be blank"
|
|
raise ArgumentError, error_msg
|
|
end
|
|
|
|
# Try to coerce to date
|
|
begin
|
|
if date_param.respond_to?(:to_date)
|
|
date_param.to_date
|
|
else
|
|
Date.parse(date_param.to_s)
|
|
end
|
|
rescue ArgumentError => e
|
|
error_msg = "Invalid #{param_name}: #{date_param} (#{e.message})"
|
|
raise ArgumentError, error_msg
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Caching
|
|
# ================================
|
|
|
|
def get_cached_result(key)
|
|
full_key = "#{@cache_prefix}_#{key}"
|
|
data = Rails.cache.read(full_key)
|
|
data
|
|
end
|
|
|
|
def cache_result(key, data)
|
|
full_key = "#{@cache_prefix}_#{key}"
|
|
Rails.cache.write(full_key, data, expires_in: CACHE_DURATION)
|
|
end
|
|
|
|
|
|
|
|
# ================================
|
|
# Helper Methods
|
|
# ================================
|
|
|
|
def generate_same_currency_rates(from, to, start_date, end_date)
|
|
(start_date..end_date).map do |date|
|
|
Rate.new(date: date, from: from, to: to, rate: 1.0)
|
|
end
|
|
end
|
|
|
|
def fetch_currency_pair_data(from, to, start_date, end_date)
|
|
symbol = "#{from}#{to}=X"
|
|
fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate|
|
|
Rate.new(
|
|
date: Time.at(timestamp).to_date,
|
|
from: from,
|
|
to: to,
|
|
rate: close_rate.to_f
|
|
)
|
|
end
|
|
end
|
|
|
|
def fetch_inverse_currency_pair_data(from, to, start_date, end_date)
|
|
symbol = "#{to}#{from}=X"
|
|
rates = fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate|
|
|
Rate.new(
|
|
date: Time.at(timestamp).to_date,
|
|
from: from,
|
|
to: to,
|
|
rate: (1.0 / close_rate.to_f).round(8)
|
|
)
|
|
end
|
|
|
|
rates
|
|
end
|
|
|
|
def fetch_chart_data(symbol, start_date, end_date, &block)
|
|
period1 = start_date.to_time.utc.to_i
|
|
period2 = end_date.end_of_day.to_time.utc.to_i
|
|
|
|
|
|
begin
|
|
response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req|
|
|
req.params["period1"] = period1
|
|
req.params["period2"] = period2
|
|
req.params["interval"] = "1d"
|
|
req.params["includeAdjustedClose"] = true
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
|
|
# Check for Yahoo Finance errors
|
|
if data.dig("chart", "error")
|
|
error_msg = data.dig("chart", "error", "description") || "Unknown Yahoo Finance error"
|
|
return nil
|
|
end
|
|
|
|
chart_data = data.dig("chart", "result", 0)
|
|
return nil unless chart_data
|
|
|
|
timestamps = chart_data.dig("timestamp") || []
|
|
quotes = chart_data.dig("indicators", "quote", 0) || {}
|
|
closes = quotes["close"] || []
|
|
|
|
results = []
|
|
timestamps.each_with_index do |timestamp, index|
|
|
close_value = closes[index]
|
|
next if close_value.nil? || close_value <= 0
|
|
|
|
results << block.call(timestamp, close_value)
|
|
end
|
|
|
|
results.sort_by(&:date)
|
|
rescue Faraday::Error => e
|
|
nil
|
|
end
|
|
end
|
|
|
|
def client
|
|
@client ||= Faraday.new(url: base_url) do |faraday|
|
|
faraday.request(:retry, {
|
|
max: 3,
|
|
interval: 0.1,
|
|
interval_randomness: 0.5,
|
|
backoff_factor: 2,
|
|
exceptions: [ Faraday::ConnectionFailed, Faraday::TimeoutError ]
|
|
})
|
|
|
|
faraday.request :json
|
|
faraday.response :raise_error
|
|
|
|
# Yahoo Finance requires common browser headers to avoid blocking
|
|
faraday.headers["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
|
faraday.headers["Accept"] = "application/json"
|
|
faraday.headers["Accept-Language"] = "en-US,en;q=0.9"
|
|
faraday.headers["Cache-Control"] = "no-cache"
|
|
faraday.headers["Pragma"] = "no-cache"
|
|
|
|
# Set reasonable timeouts
|
|
faraday.options.timeout = 10
|
|
faraday.options.open_timeout = 5
|
|
end
|
|
end
|
|
|
|
def map_country_code(exchange_name)
|
|
return nil if exchange_name.blank?
|
|
|
|
# Map common exchange names to country codes
|
|
case exchange_name.upcase.strip
|
|
when /NASDAQ|NYSE|AMEX|BATS|IEX/
|
|
"US"
|
|
when /TSX|TSXV|CSE/
|
|
"CA"
|
|
when /LSE|LONDON|AIM/
|
|
"GB"
|
|
when /TOKYO|TSE|NIKKEI|JASDAQ/
|
|
"JP"
|
|
when /ASX|AUSTRALIA/
|
|
"AU"
|
|
when /EURONEXT|PARIS|AMSTERDAM|BRUSSELS|LISBON/
|
|
case exchange_name.upcase
|
|
when /PARIS/ then "FR"
|
|
when /AMSTERDAM/ then "NL"
|
|
when /BRUSSELS/ then "BE"
|
|
when /LISBON/ then "PT"
|
|
else "FR" # Default to France for Euronext
|
|
end
|
|
when /FRANKFURT|XETRA|GETTEX/
|
|
"DE"
|
|
when /SIX|ZURICH/
|
|
"CH"
|
|
when /BME|MADRID/
|
|
"ES"
|
|
when /BORSA|MILAN/
|
|
"IT"
|
|
when /OSLO|OSE/
|
|
"NO"
|
|
when /STOCKHOLM|OMX/
|
|
"SE"
|
|
when /COPENHAGEN/
|
|
"DK"
|
|
when /HELSINKI/
|
|
"FI"
|
|
when /VIENNA/
|
|
"AT"
|
|
when /WARSAW|GPW/
|
|
"PL"
|
|
when /PRAGUE/
|
|
"CZ"
|
|
when /BUDAPEST/
|
|
"HU"
|
|
when /SHANGHAI|SHENZHEN/
|
|
"CN"
|
|
when /HONG\s*KONG|HKG/
|
|
"HK"
|
|
when /KOREA|KRX/
|
|
"KR"
|
|
when /SINGAPORE|SGX/
|
|
"SG"
|
|
when /MUMBAI|NSE|BSE/
|
|
"IN"
|
|
when /SAO\s*PAULO|BOVESPA/
|
|
"BR"
|
|
when /MEXICO|BMV/
|
|
"MX"
|
|
when /JSE|JOHANNESBURG/
|
|
"ZA"
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def map_exchange_mic(exchange_code)
|
|
return nil if exchange_code.blank?
|
|
|
|
# Map Yahoo exchange codes to MIC codes
|
|
case exchange_code.upcase.strip
|
|
when "NMS"
|
|
"XNAS" # NASDAQ Global Select
|
|
when "NGM"
|
|
"XNAS" # NASDAQ Global Market
|
|
when "NCM"
|
|
"XNAS" # NASDAQ Capital Market
|
|
when "NYQ"
|
|
"XNYS" # NYSE
|
|
when "PCX", "PSX"
|
|
"ARCX" # NYSE Arca
|
|
when "ASE", "AMX"
|
|
"XASE" # NYSE American
|
|
when "YHD"
|
|
"XNAS" # Yahoo default, assume NASDAQ
|
|
when "TSE", "TOR"
|
|
"XTSE" # Toronto Stock Exchange
|
|
when "CVE"
|
|
"XTSX" # TSX Venture Exchange
|
|
when "LSE", "LON"
|
|
"XLON" # London Stock Exchange
|
|
when "FRA"
|
|
"XFRA" # Frankfurt Stock Exchange
|
|
when "PAR"
|
|
"XPAR" # Euronext Paris
|
|
when "AMS"
|
|
"XAMS" # Euronext Amsterdam
|
|
when "BRU"
|
|
"XBRU" # Euronext Brussels
|
|
when "SWX"
|
|
"XSWX" # SIX Swiss Exchange
|
|
when "HKG"
|
|
"XHKG" # Hong Kong Stock Exchange
|
|
when "TYO"
|
|
"XJPX" # Japan Exchange Group
|
|
when "ASX"
|
|
"XASX" # Australian Securities Exchange
|
|
else
|
|
exchange_code.upcase
|
|
end
|
|
end
|
|
|
|
def map_security_type(quote_type)
|
|
case quote_type&.downcase
|
|
when "equity"
|
|
"common stock"
|
|
when "etf"
|
|
"etf"
|
|
when "mutualfund"
|
|
"mutual fund"
|
|
when "index"
|
|
"index"
|
|
else
|
|
quote_type&.downcase
|
|
end
|
|
end
|
|
|
|
# Override default error transformer to handle Yahoo Finance specific errors
|
|
def default_error_transformer(error)
|
|
case error
|
|
when Faraday::TooManyRequestsError
|
|
RateLimitError.new("Yahoo Finance rate limit exceeded", details: error.response&.dig(:body))
|
|
when Faraday::Error
|
|
Error.new(
|
|
error.message,
|
|
details: error.response&.dig(:body)
|
|
)
|
|
when Error
|
|
# Already a Yahoo Finance error, return as is
|
|
error
|
|
else
|
|
Error.new(error.message)
|
|
end
|
|
end
|
|
end
|