mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
* Fix: use cookie/crumb auth in healthy? chart endpoint check The health check was calling /v8/finance/chart/AAPL via the plain unauthenticated client. Yahoo Finance requires cookie + crumb authentication on the chart endpoint, so the health check would fail even when credentials are valid. Updated healthy? to use fetch_cookie_and_crumb + authenticated_client, consistent with fetch_security_prices and fetch_chart_data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: add cookie/crumb auth to all /v8/finance/chart/ calls fetch_security_prices and fetch_chart_data (used for exchange rates) were calling the chart endpoint without cookie/crumb authentication, inconsistent with healthy? and fetch_security_info. Added auth to both, including the same retry-on-Unauthorized pattern already used in fetch_security_info. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update user-agent strings in yahoo_finance.rb Updated user-agent strings to reflect current browser versions Signed-off-by: Serge L <serge@souritech.ca> * Fix: Add stale-crumb retry to healthy? and fetch_chart_data Yahoo Finance returns 200 OK with {"chart":{"error":{"code":"Unauthorized"}}} when a cached crumb expires server-side. Both healthy? and fetch_chart_data now mirror the retry pattern already in fetch_security_prices: detect the Unauthorized body, clear the crumb cache, fetch fresh credentials, and retry the request once. Adds a test for the healthy? retry path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Refactor: Extract fetch_authenticated_chart helper to DRY crumb retry logic The cookie/crumb fetch + stale-crumb retry pattern was duplicated across healthy?, fetch_security_prices, and fetch_chart_data. Extract it into a single private fetch_authenticated_chart(symbol, params) helper that centralizes the retry logic; all three call sites now delegate to it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Catch JSON::ParserError in fetch_chart_data rescue clause After moving JSON.parse inside fetch_authenticated_chart, a malformed Yahoo response would throw JSON::ParserError through fetch_chart_data's rescue Faraday::Error, breaking the inverse currency pair fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Raise AuthenticationError if retry still returns Unauthorized After refreshing the crumb and retrying, if Yahoo still returns an Unauthorized error body the helper now raises AuthenticationError instead of silently returning the error payload. This prevents callers from misinterpreting a persistent auth failure as missing chart data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Raise AuthenticationError after failed retry in fetch_security_info Mirrors the same post-retry Unauthorized check added to fetch_authenticated_chart. Without this, a persistent auth failure on the quoteSummary endpoint would surface as a generic "No security info found" error instead of an AuthenticationError. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Signed-off-by: Serge L <serge@souritech.ca> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
819 lines
27 KiB
Ruby
819 lines
27 KiB
Ruby
class Provider::YahooFinance < Provider
|
|
include ExchangeRateConcept, SecurityConcept
|
|
extend SslConfigurable
|
|
|
|
# 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)
|
|
AuthenticationError = Class.new(Error)
|
|
InvalidSymbolError = Class.new(Error)
|
|
MarketClosedError = Class.new(Error)
|
|
|
|
# Cache duration for repeated requests (5 minutes)
|
|
CACHE_DURATION = 5.minutes
|
|
|
|
# Maximum cache duration for cookie/crumb authentication
|
|
# Even if cookie has longer expiry, cap it to avoid stale crumbs
|
|
MAX_CRUMB_CACHE_DURATION = 1.hour
|
|
|
|
# Maximum lookback window for historical data (configurable)
|
|
MAX_LOOKBACK_WINDOW = 10.years
|
|
|
|
# Minimum delay between requests to avoid rate limiting (in seconds)
|
|
MIN_REQUEST_INTERVAL = 0.5
|
|
|
|
# Pool of modern browser user-agents to rotate through
|
|
# Based on https://github.com/ranaroussi/yfinance/pull/2277
|
|
# UPDATED user-agents string on 2026-02-27 with current versions of browsers (Chrome 145, Firefox 148, Safari 26)
|
|
USER_AGENTS = [
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0"
|
|
].freeze
|
|
|
|
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?
|
|
data = fetch_authenticated_chart("AAPL", { "interval" => "1d", "range" => "1d" })
|
|
result = data.dig("chart", "result")
|
|
result.present? && result.any?
|
|
rescue => e
|
|
false
|
|
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)
|
|
cached_result
|
|
else
|
|
throttle_request
|
|
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
|
|
end
|
|
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
|
|
# quoteSummary endpoint requires cookie/crumb authentication
|
|
throttle_request
|
|
cookie, crumb = fetch_cookie_and_crumb
|
|
|
|
response = authenticated_client(cookie).get("#{base_url}/v10/finance/quoteSummary/#{symbol}") do |req|
|
|
req.params["modules"] = "assetProfile,price,quoteType"
|
|
req.params["crumb"] = crumb
|
|
end
|
|
|
|
data = JSON.parse(response.body)
|
|
|
|
# Check for auth errors in response body
|
|
if data.dig("quoteSummary", "error", "code") == "Unauthorized"
|
|
# Clear cached crumb and retry once
|
|
clear_crumb_cache
|
|
cookie, crumb = fetch_cookie_and_crumb
|
|
response = authenticated_client(cookie).get("#{base_url}/v10/finance/quoteSummary/#{symbol}") do |req|
|
|
req.params["modules"] = "assetProfile,price,quoteType"
|
|
req.params["crumb"] = crumb
|
|
end
|
|
data = JSON.parse(response.body)
|
|
if data.dig("quoteSummary", "error", "code") == "Unauthorized"
|
|
raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh"
|
|
end
|
|
end
|
|
|
|
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)
|
|
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
|
|
|
|
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
|
|
if prices.length == 1
|
|
target_price = prices.first
|
|
else
|
|
# 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
|
|
end
|
|
|
|
cache_result(cache_key, target_price)
|
|
target_price
|
|
end
|
|
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
|
|
|
|
throttle_request
|
|
data = fetch_authenticated_chart(symbol, {
|
|
"period1" => period1,
|
|
"period2" => period2,
|
|
"interval" => "1d",
|
|
"includeAdjustedClose" => true
|
|
})
|
|
|
|
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).utc.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).utc.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).utc.to_date,
|
|
from: from,
|
|
to: to,
|
|
rate: (1.0 / close_rate.to_f).round(8)
|
|
)
|
|
end
|
|
|
|
rates
|
|
end
|
|
|
|
# Makes a single authenticated GET to /v8/finance/chart/:symbol.
|
|
# If Yahoo returns a stale-crumb error (200 OK with Unauthorized body),
|
|
# clears the crumb cache and retries once with fresh credentials.
|
|
def fetch_authenticated_chart(symbol, params)
|
|
cookie, crumb = fetch_cookie_and_crumb
|
|
response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req|
|
|
params.each { |k, v| req.params[k] = v }
|
|
req.params["crumb"] = crumb
|
|
end
|
|
data = JSON.parse(response.body)
|
|
|
|
if data.dig("chart", "error", "code") == "Unauthorized"
|
|
clear_crumb_cache
|
|
cookie, crumb = fetch_cookie_and_crumb
|
|
response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req|
|
|
params.each { |k, v| req.params[k] = v }
|
|
req.params["crumb"] = crumb
|
|
end
|
|
data = JSON.parse(response.body)
|
|
if data.dig("chart", "error", "code") == "Unauthorized"
|
|
raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh"
|
|
end
|
|
end
|
|
|
|
data
|
|
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
|
|
throttle_request
|
|
data = fetch_authenticated_chart(symbol, {
|
|
"period1" => period1,
|
|
"period2" => period2,
|
|
"interval" => "1d",
|
|
"includeAdjustedClose" => true
|
|
})
|
|
|
|
# Check for Yahoo Finance errors
|
|
if data.dig("chart", "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, JSON::ParserError => e
|
|
nil
|
|
end
|
|
end
|
|
|
|
def client
|
|
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
|
faraday.request(:retry, {
|
|
max: max_retries,
|
|
interval: retry_interval,
|
|
interval_randomness: 0.5,
|
|
backoff_factor: 2,
|
|
retry_statuses: [ 429 ],
|
|
exceptions: [ Faraday::ConnectionFailed, Faraday::TimeoutError ]
|
|
})
|
|
|
|
faraday.request :json
|
|
faraday.response :raise_error
|
|
|
|
# Yahoo Finance requires common browser headers to avoid blocking
|
|
# Rotate user-agents to reduce rate limiting (based on yfinance PR #2277)
|
|
faraday.headers["User-Agent"] = random_user_agent
|
|
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 random_user_agent
|
|
USER_AGENTS.sample
|
|
end
|
|
|
|
def max_retries
|
|
ENV.fetch("YAHOO_FINANCE_MAX_RETRIES", 5).to_i
|
|
end
|
|
|
|
def retry_interval
|
|
ENV.fetch("YAHOO_FINANCE_RETRY_INTERVAL", 1.0).to_f
|
|
end
|
|
|
|
def min_request_interval
|
|
ENV.fetch("YAHOO_FINANCE_MIN_REQUEST_INTERVAL", MIN_REQUEST_INTERVAL).to_f
|
|
end
|
|
|
|
def throttle_request
|
|
@last_request_time ||= Time.at(0)
|
|
elapsed = Time.current - @last_request_time
|
|
sleep_time = min_request_interval - elapsed
|
|
sleep(sleep_time) if sleep_time > 0
|
|
@last_request_time = Time.current
|
|
end
|
|
|
|
# ================================
|
|
# Cookie/Crumb Authentication
|
|
# ================================
|
|
|
|
# Fetches and caches the Yahoo Finance cookie and crumb for authenticated endpoints
|
|
# The crumb is a CSRF token required by some Yahoo Finance endpoints (e.g., quoteSummary)
|
|
def fetch_cookie_and_crumb
|
|
cache_key = "#{@cache_prefix}_auth_crumb"
|
|
cached = Rails.cache.read(cache_key)
|
|
return cached if cached.present?
|
|
|
|
# Step 1: Get cookie from Yahoo Finance
|
|
cookie_response = auth_client.get("https://fc.yahoo.com")
|
|
cookie = extract_cookie(cookie_response)
|
|
cookie_max_age = extract_cookie_max_age(cookie_response)
|
|
|
|
raise AuthenticationError, "Failed to obtain Yahoo Finance cookie" if cookie.blank?
|
|
|
|
# Step 2: Get crumb using the cookie
|
|
crumb_response = auth_client.get("#{base_url}/v1/test/getcrumb") do |req|
|
|
req.headers["Cookie"] = cookie
|
|
end
|
|
|
|
crumb = crumb_response.body.strip
|
|
|
|
raise AuthenticationError, "Failed to obtain Yahoo Finance crumb" if crumb.blank?
|
|
|
|
# Cache the cookie/crumb pair using cookie's max-age, capped at MAX_CRUMB_CACHE_DURATION
|
|
cache_duration = [ cookie_max_age || MAX_CRUMB_CACHE_DURATION, MAX_CRUMB_CACHE_DURATION ].min
|
|
result = [ cookie, crumb ]
|
|
Rails.cache.write(cache_key, result, expires_in: cache_duration)
|
|
result
|
|
rescue Faraday::Error => e
|
|
raise AuthenticationError, "Failed to authenticate with Yahoo Finance: #{e.message}"
|
|
end
|
|
|
|
def clear_crumb_cache
|
|
Rails.cache.delete("#{@cache_prefix}_auth_crumb")
|
|
end
|
|
|
|
# Extract the authentication cookie from Yahoo Finance response
|
|
def extract_cookie(response)
|
|
set_cookie = response.headers["set-cookie"]
|
|
return nil if set_cookie.blank?
|
|
|
|
# Extract the cookie value (format: "A3=d=xxx&S=xxx; Max-Age=31557600; ...")
|
|
# We only need the part before the first semicolon
|
|
set_cookie.split(";").first
|
|
end
|
|
|
|
# Extract Max-Age from cookie header and convert to seconds
|
|
# Format: "...; Max-Age=31557600; ..."
|
|
def extract_cookie_max_age(response)
|
|
set_cookie = response.headers["set-cookie"]
|
|
return nil if set_cookie.blank?
|
|
|
|
max_age_match = set_cookie.match(/Max-Age=(\d+)/i)
|
|
return nil unless max_age_match
|
|
|
|
max_age_match[1].to_i.seconds
|
|
end
|
|
|
|
# Client for authentication requests (no error raising - fc.yahoo.com returns 404 but sets cookie)
|
|
def auth_client
|
|
@auth_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) do |faraday|
|
|
faraday.headers["User-Agent"] = random_user_agent
|
|
faraday.headers["Accept"] = "*/*"
|
|
faraday.headers["Accept-Language"] = "en-US,en;q=0.9"
|
|
faraday.options.timeout = 10
|
|
faraday.options.open_timeout = 5
|
|
end
|
|
end
|
|
|
|
# Client for authenticated requests (includes cookie header)
|
|
def authenticated_client(cookie)
|
|
Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
|
faraday.request(:retry, {
|
|
max: max_retries,
|
|
interval: retry_interval,
|
|
interval_randomness: 0.5,
|
|
backoff_factor: 2,
|
|
retry_statuses: [ 429 ],
|
|
exceptions: [ Faraday::ConnectionFailed, Faraday::TimeoutError ]
|
|
})
|
|
|
|
faraday.request :json
|
|
faraday.response :raise_error
|
|
|
|
faraday.headers["User-Agent"] = random_user_agent
|
|
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"
|
|
faraday.headers["Cookie"] = cookie
|
|
|
|
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::UnauthorizedError
|
|
# 401 indicates missing or invalid crumb/cookie authentication
|
|
AuthenticationError.new("Yahoo Finance authentication failed (invalid crumb)", details: error.response&.dig(:body))
|
|
when AuthenticationError
|
|
# Already an authentication error, return as is
|
|
error
|
|
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
|