mirror of
https://github.com/we-promise/sure.git
synced 2026-04-12 08:37:22 +00:00
* 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
266 lines
9.5 KiB
Ruby
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
|