module Security::Provided extend ActiveSupport::Concern SecurityInfoMissingError = Class.new(StandardError) class_methods do # Returns all enabled and configured securities providers def providers Setting.enabled_securities_providers.filter_map do |name| Provider::Registry.for_concept(:securities).get_provider(name.to_sym) rescue Provider::Registry::Error nil end end # Backward compat: first enabled provider def provider providers.first end # Get a specific provider by key name (e.g., "finnhub", "twelve_data") # Returns nil if the provider is disabled in settings or not configured. def provider_for(name) return nil if name.blank? return nil unless Setting.enabled_securities_providers.include?(name.to_s) Provider::Registry.for_concept(:securities).get_provider(name.to_sym) rescue Provider::Registry::Error nil end # Cache duration for search results (avoids burning through provider rate limits) SEARCH_CACHE_TTL = 5.minutes # Maximum number of results returned to the combobox dropdown MAX_SEARCH_RESULTS = 30 # Per-provider timeout so one slow provider can't stall the entire search PROVIDER_SEARCH_TIMEOUT = 8.seconds def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) return [] if symbol.blank? active_providers = providers.compact return [] if active_providers.empty? params = { country_code: country_code, exchange_operating_mic: exchange_operating_mic }.compact_blank # Query all providers concurrently so the total wall time is max(provider # latencies) instead of sum. Each future runs in the concurrent-ruby thread # pool, keeping Puma threads unblocked during individual provider sleeps. futures = active_providers.map do |prov| Concurrent::Promises.future(prov) do |provider| fetch_provider_results(provider, symbol, params) end end # Collect results from each future individually with a shared deadline. # Unlike zip (which is all-or-nothing), this keeps results from fast # providers even when a slow one times out. deadline = Time.current + PROVIDER_SEARCH_TIMEOUT results_array = futures.map do |future| remaining = [ (deadline - Time.current), 0 ].max future.value(remaining) end all_results = [] seen_keys = Set.new results_array.each_with_index do |provider_results, idx| next if provider_results.nil? provider_key = provider_key_for(active_providers[idx]) provider_results.each do |ps| # Dedup key includes provider so the same ticker on the same exchange can # appear once per provider — the user picks which provider's price feed # they want and that choice is stored in price_provider. dedup_key = "#{ps[:symbol]}|#{ps[:exchange_operating_mic]}|#{provider_key}".upcase next if seen_keys.include?(dedup_key) seen_keys.add(dedup_key) security = Security.new( ticker: ps[:symbol], name: ps[:name], logo_url: ps[:logo_url], exchange_operating_mic: ps[:exchange_operating_mic], country_code: ps[:country_code], search_currency: ps[:currency], price_provider: provider_key ) all_results << security end end if all_results.empty? && active_providers.any? Rails.logger.warn("Security search: all #{active_providers.size} providers returned no results for '#{symbol}'") end rank_search_results(all_results, symbol, country_code).first(MAX_SEARCH_RESULTS) end private def provider_key_for(provider_instance) provider_instance.class.name.demodulize.underscore end # Fetches (or reads from cache) search results for a single provider. # Designed to run inside a Concurrent::Promises.future. def fetch_provider_results(prov, symbol, params) provider_key = provider_key_for(prov) cache_key = "security_search:#{provider_key}:#{symbol.upcase}:#{Digest::SHA256.hexdigest(params.sort_by { |k, _| k }.to_json)}" Rails.cache.fetch(cache_key, expires_in: SEARCH_CACHE_TTL, skip_nil: true) do response = prov.search_securities(symbol, **params) next nil unless response.success? response.data.map do |ps| { symbol: ps.symbol, name: ps.name, logo_url: ps.logo_url, exchange_operating_mic: ps.exchange_operating_mic, country_code: ps.country_code, currency: ps.respond_to?(:currency) ? ps.currency : nil } end end rescue => e Rails.logger.warn("Security search failed for #{provider_key}: #{e.message}") nil end # Scores and sorts search results so the most relevant matches appear first. # Scoring criteria (lower = better): # 0: exact ticker match # 1: ticker starts with query # 2: name contains query # 3: everything else # Within the same relevance tier, user's country is preferred. def rank_search_results(results, symbol, country_code) query = symbol.upcase user_country = country_code&.upcase results.sort_by do |s| ticker_up = s.ticker.upcase relevance = if ticker_up == query 0 elsif ticker_up.start_with?(query) 1 elsif s.name&.upcase&.include?(query) 2 else 3 end country_match = (user_country.present? && s.country_code&.upcase == user_country) ? 0 : 1 [ relevance, country_match, ticker_up ] end end end # Public method: resolves the provider for this specific security. # Uses the security's assigned price_provider if available and configured. # Falls back to the first enabled provider only when no specific provider # was ever assigned. When an assigned provider becomes unavailable, returns # nil so the security is skipped rather than queried against an incompatible # provider (e.g. MFAPI scheme codes sent to TwelveData). def price_data_provider if price_provider.present? assigned = self.class.provider_for(price_provider) return assigned if assigned.present? return nil # assigned provider is unavailable — don't silently fall back end self.class.providers.first end # Returns the health status of this security's provider link. # Delegates to price_data_provider to avoid duplicating provider lookup logic. def provider_status resolved = price_data_provider # Had a specific provider assigned but it's now unavailable return :provider_unavailable if resolved.nil? && price_provider.present? return :offline if offline? return :no_provider if resolved.nil? return :stale if failed_fetch_count.to_i > 0 :ok end def find_or_fetch_price(date: Date.current, cache: true) price = prices.find_by(date: date) return price if price.present? # Don't fetch prices for offline securities (e.g., custom tickers from SimpleFIN) return nil if offline? # Make sure we have a data provider before fetching return nil unless price_data_provider.present? response = price_data_provider.fetch_security_price( symbol: ticker, exchange_operating_mic: exchange_operating_mic, date: date ) return nil unless response.success? # Provider error price = response.data Security::Price.find_or_create_by!( security_id: self.id, date: price.date, price: price.price, currency: price.currency ) if cache price end def import_provider_details(clear_cache: false) unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_details") return end if self.name.present? && (self.logo_url.present? || self.website_url.present?) && !clear_cache return end response = price_data_provider.fetch_security_info( symbol: ticker, exchange_operating_mic: exchange_operating_mic ) if response.success? # Only overwrite fields the provider actually returned, so providers that # don't support metadata (e.g. Alpha Vantage) won't blank existing values. attrs = {} attrs[:name] = response.data.name if response.data.name.present? attrs[:logo_url] = response.data.logo_url if response.data.logo_url.present? attrs[:website_url] = response.data.links if response.data.links.present? update(attrs) if attrs.any? else Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}") Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope| scope.set_tags(security_id: self.id) scope.set_context("security", { id: self.id, provider_error: response.error.message }) end end end def import_provider_prices(start_date:, end_date:, clear_cache: false) unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_prices") return 0 end importer = Security::Price::Importer.new( security: self, security_provider: price_data_provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache ) [ importer.import_provider_prices, importer.provider_error ] end end