Files
sure/app/models/security/provided.rb
soky srm 7908f7d8a4 Expand financial providers (#1407)
* Initial implementation

* Tiingo fixes

* Adds 2 providers, remove 2

* Add  extra checks

* FIX a big hotwire race condition

// Fix hotwire_combobox race condition: when typing quickly, a slow response for
// an early query (e.g. "A") can overwrite the correct results for the final query
// (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires,
// so stale Turbo Stream responses never reach the DOM.

* pipelock

* Update price_test.rb

* Reviews

* i8n

* fixes

* fixes

* Update tiingo.rb

* fixes

* Improvements

* Big revamp

* optimisations

* Update 20260408151837_add_offline_reason_to_securities.rb

* Add missing tests, fixes

* small rank tests

* FIX tests

* Update show.html.erb

* Update resolver.rb

* Update usd_converter.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update holdings_controller.rb

* Update _yahoo_finance_settings.html.erb
2026-04-09 18:33:59 +02:00

266 lines
9.5 KiB
Ruby

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