From 9fefe57de57e9e61110695af341af7cd76e2b2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Felipe?= <37910709+joaofrenor@users.noreply.github.com> Date: Wed, 29 Oct 2025 02:15:14 +0400 Subject: [PATCH] Feature/yahoo finance (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Juan José Mata --- .env.example | 7 +- .../settings/hostings_controller.rb | 2 + app/models/provider/registry.rb | 8 +- app/models/provider/yahoo_finance.rb | 603 ++++++++++++++++++ .../hostings/_yahoo_finance_settings.html.erb | 37 ++ app/views/settings/hostings/show.html.erb | 8 +- config/locales/views/settings/hostings/en.yml | 14 +- config/locales/views/settings/hostings/nb.yml | 11 +- config/locales/views/settings/hostings/tr.yml | 10 +- .../settings/hostings_controller_test.rb | 7 +- test/models/provider/yahoo_finance_test.rb | 218 +++++++ test/system/settings_test.rb | 1 + 12 files changed, 913 insertions(+), 13 deletions(-) create mode 100644 app/models/provider/yahoo_finance.rb create mode 100644 app/views/settings/hostings/_yahoo_finance_settings.html.erb create mode 100644 test/models/provider/yahoo_finance_test.rb diff --git a/.env.example b/.env.example index 0a0d75aec..174ba9c70 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 048a1c463..07aaa5b10 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -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 diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 3d5af2f62..f98fb1e5e 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -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 diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb new file mode 100644 index 000000000..2f934f9ba --- /dev/null +++ b/app/models/provider/yahoo_finance.rb @@ -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 diff --git a/app/views/settings/hostings/_yahoo_finance_settings.html.erb b/app/views/settings/hostings/_yahoo_finance_settings.html.erb new file mode 100644 index 000000000..4bd496945 --- /dev/null +++ b/app/views/settings/hostings/_yahoo_finance_settings.html.erb @@ -0,0 +1,37 @@ +
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ <% if @yahoo_finance_provider&.healthy? %> +
+
+
+

+ <%= t(".status_active") %> +

+
+
+ <% else %> +
+
+
+

+ <%= t(".status_inactive") %> +

+
+
+
+
+

+ <%= t(".connection_failed") %> +

+
+

<%= t(".troubleshooting") %>

+
+
+
+
+
+ <% end %> +
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 077cd445a..b406d67d3 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -1,17 +1,19 @@ <%= content_for :page_title, t(".title") %> - <%= settings_section title: t(".general") do %>
<%= render "settings/hostings/openai_settings" %> <%= render "settings/hostings/brand_fetch_settings" %> +
+<% end %> +<%= settings_section title: t(".financial_data_providers") do %> +
+ <%= render "settings/hostings/yahoo_finance_settings" %> <%= render "settings/hostings/twelve_data_settings" %>
<% 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 %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index e769dfdcf..1751c79cf 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -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 diff --git a/config/locales/views/settings/hostings/nb.yml b/config/locales/views/settings/hostings/nb.yml index 923b89d29..800dc5eab 100644 --- a/config/locales/views/settings/hostings/nb.yml +++ b/config/locales/views/settings/hostings/nb.yml @@ -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. + diff --git a/config/locales/views/settings/hostings/tr.yml b/config/locales/views/settings/hostings/tr.yml index 551fdc784..cc43985f0 100644 --- a/config/locales/views/settings/hostings/tr.yml +++ b/config/locales/views/settings/hostings/tr.yml @@ -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. diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index a3c4be0ba..de94b050a 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -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 diff --git a/test/models/provider/yahoo_finance_test.rb b/test/models/provider/yahoo_finance_test.rb new file mode 100644 index 000000000..0bf9bb974 --- /dev/null +++ b/test/models/provider/yahoo_finance_test.rb @@ -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 diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index deec32671..51fb26243 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -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"