mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 06:21:23 +00:00
Feature/yahoo finance (#123)
* Implement Yahoo Finance * Added tests * Updated hosting controller to check for managed app_mode instead of env_override * Suggestions from CodeRabbit and Fixes on tests * Remove Css changes * Fix yahoo finance impl and i18n * Updated view to use healthy method * remove usage * Updated env example * keep usage on class just to keep same format * Ci test * Remove some useless validations * Remove logs * Linter fixes * Broke this in my conflict merge * Wrong indentation level --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -35,10 +35,15 @@ LANGFUSE_SECRET_KEY=
|
||||
# Get it here: https://twelvedata.com/
|
||||
TWELVE_DATA_API_KEY=
|
||||
|
||||
# Optional: Twelve Data provider is the default for exchange rates and securities.
|
||||
# Optional: Provider selection for exchange rates and securities data
|
||||
# Options: twelve_data (default), yahoo_finance
|
||||
EXCHANGE_RATE_PROVIDER=twelve_data
|
||||
SECURITIES_PROVIDER=twelve_data
|
||||
|
||||
# Alternative: Use Yahoo Finance as provider (free, no API key required)
|
||||
# EXCHANGE_RATE_PROVIDER=yahoo_finance
|
||||
# SECURITIES_PROVIDER=yahoo_finance
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
@@ -12,6 +12,8 @@ class Settings::HostingsController < ApplicationController
|
||||
]
|
||||
twelve_data_provider = Provider::Registry.get_provider(:twelve_data)
|
||||
@twelve_data_usage = twelve_data_provider&.usage
|
||||
|
||||
@yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -75,6 +75,10 @@ class Provider::Registry
|
||||
|
||||
Provider::Openai.new(access_token, uri_base: uri_base, model: model)
|
||||
end
|
||||
|
||||
def yahoo_finance
|
||||
Provider::YahooFinance.new
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(concept)
|
||||
@@ -100,9 +104,9 @@ class Provider::Registry
|
||||
def available_providers
|
||||
case concept
|
||||
when :exchange_rates
|
||||
%i[twelve_data]
|
||||
%i[twelve_data yahoo_finance]
|
||||
when :securities
|
||||
%i[twelve_data]
|
||||
%i[twelve_data yahoo_finance]
|
||||
when :llm
|
||||
%i[openai]
|
||||
else
|
||||
|
||||
603
app/models/provider/yahoo_finance.rb
Normal file
603
app/models/provider/yahoo_finance.rb
Normal file
@@ -0,0 +1,603 @@
|
||||
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
|
||||
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)
|
||||
|
||||
prices << Price.new(
|
||||
symbol: symbol,
|
||||
date: Time.at(timestamp).to_date,
|
||||
price: close_price.to_f,
|
||||
currency: 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
|
||||
|
||||
# ================================
|
||||
# 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
|
||||
37
app/views/settings/hostings/_yahoo_finance_settings.html.erb
Normal file
37
app/views/settings/hostings/_yahoo_finance_settings.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="font-medium mb-1"><%= t(".title") %></h2>
|
||||
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
|
||||
</div>
|
||||
<% if @yahoo_finance_provider&.healthy? %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".status_active") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".status_inactive") %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= t(".connection_failed") %>
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<p><%= t(".troubleshooting") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,17 +1,19 @@
|
||||
<%= content_for :page_title, t(".title") %>
|
||||
|
||||
<%= settings_section title: t(".general") do %>
|
||||
<div class="space-y-6">
|
||||
<%= render "settings/hostings/openai_settings" %>
|
||||
<%= render "settings/hostings/brand_fetch_settings" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".financial_data_providers") do %>
|
||||
<div class="space-y-6">
|
||||
<%= render "settings/hostings/yahoo_finance_settings" %>
|
||||
<%= render "settings/hostings/twelve_data_settings" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".invites") do %>
|
||||
<%= render "settings/hostings/invite_code_settings" %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".danger_zone") do %>
|
||||
<%= render "settings/hostings/danger_zone_settings" %>
|
||||
<% end %>
|
||||
|
||||
@@ -15,8 +15,9 @@ en:
|
||||
closed: Closed
|
||||
invite_only: Invite-only
|
||||
show:
|
||||
general: External Services
|
||||
invites: Onboarding
|
||||
general: General Settings
|
||||
financial_data_providers: Financial Data Providers
|
||||
invites: Invite Codes
|
||||
title: Self-Hosting
|
||||
danger_zone: Danger Zone
|
||||
clear_cache: Clear data cache
|
||||
@@ -28,7 +29,7 @@ en:
|
||||
description: Enter the Client ID provided by Brand Fetch
|
||||
label: Client ID
|
||||
placeholder: Enter your Client ID here
|
||||
title: Brand Fetch
|
||||
title: Brand Fetch Settings
|
||||
openai_settings:
|
||||
description: Enter the access token and optionally configure a custom OpenAI-compatible provider
|
||||
env_configured_message: Successfully configured through environment variables.
|
||||
@@ -39,6 +40,13 @@ en:
|
||||
model_label: Model (Optional)
|
||||
model_placeholder: "gpt-4.1 (default)"
|
||||
title: OpenAI
|
||||
yahoo_finance_settings:
|
||||
title: Yahoo Finance
|
||||
description: Yahoo Finance provides free access to stock prices, exchange rates, and financial data without requiring an API key.
|
||||
status_active: Yahoo Finance is active and working
|
||||
status_inactive: Yahoo Finance connection failed
|
||||
connection_failed: Unable to connect to Yahoo Finance
|
||||
troubleshooting: Check your internet connection and firewall settings. Yahoo Finance may be temporarily unavailable.
|
||||
twelve_data_settings:
|
||||
api_calls_used: "%{used} / %{limit} API daily calls used (%{percentage})"
|
||||
description: Enter the API key provided by Twelve Data
|
||||
|
||||
@@ -16,7 +16,8 @@ nb:
|
||||
invite_only: Kun invitasjon
|
||||
show:
|
||||
general: Generelle innstillinger
|
||||
invites: Onboarding
|
||||
financial_data_providers: Finansdata-leverandører
|
||||
invites: Invitasjonskoder
|
||||
title: Selvhosting
|
||||
danger_zone: Fareområde
|
||||
clear_cache: Tøm cache
|
||||
@@ -31,3 +32,11 @@ nb:
|
||||
clear_cache:
|
||||
cache_cleared: Cachen er tømt. Dette kan ta noen øyeblikk å fullføre.
|
||||
not_authorized: Du er ikke autorisert til å utføre denne handlingen
|
||||
yahoo_finance_settings:
|
||||
title: Yahoo Finance
|
||||
description: Yahoo Finance gir gratis tilgang til aksjekurser, valutakurser og finansdata uten å kreve en API-nøkkel.
|
||||
status_active: Yahoo Finance er aktiv og fungerer
|
||||
status_inactive: Yahoo Finance-tilkobling feilet
|
||||
connection_failed: Kunne ikke koble til Yahoo Finance
|
||||
troubleshooting: Sjekk internettilkoblingen og brannmurinnstillingene. Yahoo Finance kan være midlertidig utilgjengelig.
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ tr:
|
||||
invite_only: Davet ile
|
||||
show:
|
||||
general: Genel Ayarlar
|
||||
invites: Onboarding
|
||||
financial_data_providers: Finansal Veri Sağlayıcıları
|
||||
invites: Davet Kodları
|
||||
title: Kendi Sunucunda Barındırma
|
||||
danger_zone: Tehlikeli Bölge
|
||||
clear_cache: Veri önbelleğini temizle
|
||||
@@ -37,3 +38,10 @@ tr:
|
||||
clear_cache:
|
||||
cache_cleared: Veri önbelleği temizlendi. Bu işlemin tamamlanması birkaç dakika sürebilir.
|
||||
not_authorized: Bu işlemi gerçekleştirmek için yetkiniz yok
|
||||
yahoo_finance_settings:
|
||||
title: Yahoo Finance
|
||||
description: Yahoo Finance, API anahtarı gerektirmeden hisse senedi fiyatları, döviz kurları ve finansal verilere ücretsiz erişim sağlar.
|
||||
status_active: Yahoo Finance aktif ve çalışıyor
|
||||
status_inactive: Yahoo Finance bağlantısı başarısız
|
||||
connection_failed: Yahoo Finance'e bağlanılamıyor
|
||||
troubleshooting: İnternet bağlantınızı ve güvenlik duvarı ayarlarınızı kontrol edin. Yahoo Finance geçici olarak kullanılamayabilir.
|
||||
|
||||
@@ -9,14 +9,17 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
@provider = mock
|
||||
Provider::Registry.stubs(:get_provider).with(:twelve_data).returns(@provider)
|
||||
@usage_response = provider_success_response(
|
||||
|
||||
@provider.stubs(:healthy?).returns(true)
|
||||
Provider::Registry.stubs(:get_provider).with(:yahoo_finance).returns(@provider)
|
||||
@provider.stubs(:usage).returns(provider_success_response(
|
||||
OpenStruct.new(
|
||||
used: 10,
|
||||
limit: 100,
|
||||
utilization: 10,
|
||||
plan: "free",
|
||||
)
|
||||
)
|
||||
))
|
||||
end
|
||||
|
||||
test "cannot edit when self hosting is disabled" do
|
||||
|
||||
218
test/models/provider/yahoo_finance_test.rb
Normal file
218
test/models/provider/yahoo_finance_test.rb
Normal file
@@ -0,0 +1,218 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::YahooFinanceTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::YahooFinance.new
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Health Check Tests
|
||||
# ================================
|
||||
|
||||
test "healthy? returns true when API is working" do
|
||||
# Mock successful response
|
||||
mock_response = mock
|
||||
mock_response.stubs(:body).returns('{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}')
|
||||
|
||||
@provider.stubs(:client).returns(mock_client = mock)
|
||||
mock_client.stubs(:get).returns(mock_response)
|
||||
|
||||
assert @provider.healthy?
|
||||
end
|
||||
|
||||
test "healthy? returns false when API fails" do
|
||||
# Mock failed response
|
||||
@provider.stubs(:client).returns(mock_client = mock)
|
||||
mock_client.stubs(:get).raises(Faraday::Error.new("Connection failed"))
|
||||
|
||||
assert_not @provider.healthy?
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Exchange Rate Tests
|
||||
# ================================
|
||||
|
||||
test "fetch_exchange_rate returns 1.0 for same currency" do
|
||||
date = Date.parse("2024-01-15")
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "USD", date: date)
|
||||
|
||||
assert response.success?
|
||||
rate = response.data
|
||||
assert_equal 1.0, rate.rate
|
||||
assert_equal "USD", rate.from
|
||||
assert_equal "USD", rate.to
|
||||
assert_equal date, rate.date
|
||||
end
|
||||
|
||||
test "fetch_exchange_rate handles invalid currency codes" do
|
||||
date = Date.parse("2024-01-15")
|
||||
|
||||
# With validation removed, invalid currencies will result in API errors
|
||||
response = @provider.fetch_exchange_rate(from: "INVALID", to: "USD", date: date)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "USD", to: "INVALID", date: date)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
|
||||
response = @provider.fetch_exchange_rate(from: "", to: "USD", date: date)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates returns same currency rates" do
|
||||
start_date = Date.parse("2024-01-10")
|
||||
end_date = Date.parse("2024-01-12")
|
||||
response = @provider.fetch_exchange_rates(from: "USD", to: "USD", start_date: start_date, end_date: end_date)
|
||||
|
||||
assert response.success?
|
||||
rates = response.data
|
||||
expected_dates = (start_date..end_date).to_a
|
||||
assert_equal expected_dates.length, rates.length
|
||||
assert rates.all? { |r| r.rate == 1.0 }
|
||||
assert rates.all? { |r| r.from == "USD" }
|
||||
assert rates.all? { |r| r.to == "USD" }
|
||||
end
|
||||
|
||||
test "fetch_exchange_rates validates date range" do
|
||||
response = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current, end_date: Date.current - 1.day)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
|
||||
response = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current - 6.years, end_date: Date.current)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Security Search Tests
|
||||
# ================================
|
||||
|
||||
test "search_securities handles invalid symbols" do
|
||||
# With validation removed, invalid symbols will result in API errors
|
||||
response = @provider.search_securities("")
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
|
||||
response = @provider.search_securities("VERYLONGSYMBOLNAME")
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
|
||||
response = @provider.search_securities("INVALID@SYMBOL")
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
end
|
||||
|
||||
test "search_securities returns empty array for no results with short symbol" do
|
||||
# Mock empty results response
|
||||
mock_response = mock
|
||||
mock_response.stubs(:body).returns('{"quotes":[]}')
|
||||
|
||||
@provider.stubs(:client).returns(mock_client = mock)
|
||||
mock_client.stubs(:get).returns(mock_response)
|
||||
|
||||
response = @provider.search_securities("XYZ")
|
||||
assert response.success?
|
||||
assert_equal [], response.data
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Security Price Tests
|
||||
# ================================
|
||||
|
||||
test "fetch_security_price handles invalid symbol" do
|
||||
date = Date.parse("2024-01-15")
|
||||
|
||||
# With validation removed, invalid symbols will result in API errors
|
||||
response = @provider.fetch_security_price(symbol: "", exchange_operating_mic: "XNAS", date: date)
|
||||
assert_not response.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, response.error
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Caching Tests
|
||||
# ================================
|
||||
|
||||
# Note: Caching tests are skipped as Rails.cache may not be properly configured in test environment
|
||||
# and caching functionality is not the focus of the validation fixes
|
||||
|
||||
# ================================
|
||||
# Error Handling Tests
|
||||
# ================================
|
||||
|
||||
test "handles Faraday errors gracefully" do
|
||||
# Mock a Faraday error
|
||||
faraday_error = Faraday::ConnectionFailed.new("Connection failed")
|
||||
|
||||
@provider.stub :client, ->(*) { raise faraday_error } do
|
||||
result = @provider.send(:with_provider_response) { raise faraday_error }
|
||||
|
||||
assert_not result.success?
|
||||
assert_instance_of Provider::YahooFinance::Error, result.error
|
||||
end
|
||||
end
|
||||
|
||||
test "handles rate limit errors" do
|
||||
rate_limit_error = Faraday::TooManyRequestsError.new("Rate limit exceeded", { body: "Too many requests" })
|
||||
|
||||
@provider.stub :client, ->(*) { raise rate_limit_error } do
|
||||
result = @provider.send(:with_provider_response) { raise rate_limit_error }
|
||||
|
||||
assert_not result.success?
|
||||
assert_instance_of Provider::YahooFinance::RateLimitError, result.error
|
||||
end
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Helper Method Tests
|
||||
# ================================
|
||||
|
||||
test "map_country_code returns correct codes for exchanges" do
|
||||
assert_equal "US", @provider.send(:map_country_code, "NASDAQ")
|
||||
assert_equal "US", @provider.send(:map_country_code, "NYSE")
|
||||
assert_equal "GB", @provider.send(:map_country_code, "LSE")
|
||||
assert_equal "JP", @provider.send(:map_country_code, "TOKYO")
|
||||
assert_equal "CA", @provider.send(:map_country_code, "TSX")
|
||||
assert_equal "DE", @provider.send(:map_country_code, "FRANKFURT")
|
||||
assert_nil @provider.send(:map_country_code, "UNKNOWN")
|
||||
assert_nil @provider.send(:map_country_code, "")
|
||||
end
|
||||
|
||||
test "map_exchange_mic returns correct MIC codes" do
|
||||
assert_equal "XNAS", @provider.send(:map_exchange_mic, "NMS")
|
||||
assert_equal "XNAS", @provider.send(:map_exchange_mic, "NGM")
|
||||
assert_equal "XNYS", @provider.send(:map_exchange_mic, "NYQ")
|
||||
assert_equal "XLON", @provider.send(:map_exchange_mic, "LSE")
|
||||
assert_equal "XTSE", @provider.send(:map_exchange_mic, "TSE")
|
||||
assert_equal "UNKNOWN", @provider.send(:map_exchange_mic, "UNKNOWN")
|
||||
assert_nil @provider.send(:map_exchange_mic, "")
|
||||
end
|
||||
|
||||
test "map_security_type returns correct types" do
|
||||
assert_equal "common stock", @provider.send(:map_security_type, "equity")
|
||||
assert_equal "etf", @provider.send(:map_security_type, "etf")
|
||||
assert_equal "mutual fund", @provider.send(:map_security_type, "mutualfund")
|
||||
assert_equal "index", @provider.send(:map_security_type, "index")
|
||||
assert_equal "unknown", @provider.send(:map_security_type, "unknown")
|
||||
assert_nil @provider.send(:map_security_type, nil)
|
||||
end
|
||||
|
||||
|
||||
|
||||
test "validate_date_range! raises errors for invalid ranges" do
|
||||
assert_raises(Provider::YahooFinance::Error) do
|
||||
@provider.send(:validate_date_range!, Date.current, Date.current - 1.day)
|
||||
end
|
||||
|
||||
assert_raises(Provider::YahooFinance::Error) do
|
||||
@provider.send(:validate_date_range!, Date.current - 6.years - 1.day, Date.current)
|
||||
end
|
||||
|
||||
# Should not raise for valid ranges
|
||||
assert_nothing_raised do
|
||||
@provider.send(:validate_date_range!, Date.current - 1.year, Date.current)
|
||||
@provider.send(:validate_date_range!, Date.current - 5.years, Date.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,7 @@ class SettingsTest < ApplicationSystemTestCase
|
||||
test "can update self hosting settings" do
|
||||
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
||||
Provider::Registry.stubs(:get_provider).with(:twelve_data).returns(nil)
|
||||
Provider::Registry.stubs(:get_provider).with(:yahoo_finance).returns(nil)
|
||||
open_settings_from_sidebar
|
||||
assert_selector "li", text: "Self-Hosting"
|
||||
click_link "Self-Hosting"
|
||||
|
||||
Reference in New Issue
Block a user