class Security::Resolver def initialize(symbol, exchange_operating_mic: nil, country_code: nil, price_provider: nil) @symbol = validate_symbol!(symbol) @exchange_operating_mic = exchange_operating_mic @country_code = country_code @price_provider = validated_price_provider(price_provider) end # Attempts several paths to resolve a security: # 1. Exact match in DB # 2. Search provider for an exact match # 3. Search provider for close match, ranked by relevance # 4. Create offline security if no match is found in either DB or provider def resolve return nil if symbol.blank? exact_match_from_db || exact_match_from_provider || close_match_from_provider || offline_security end private attr_reader :symbol, :exchange_operating_mic, :country_code, :price_provider def validate_symbol!(symbol) raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank? symbol.strip.upcase end # Only accept price_provider values that are known and currently enabled. # Prevents tampered combobox values from persisting invalid provider names. def validated_price_provider(value) return nil if value.blank? return nil unless Security.valid_price_providers.include?(value.to_s) return nil unless Setting.enabled_securities_providers.include?(value.to_s) value.to_s end def offline_security security = Security.find_or_initialize_by( ticker: symbol, exchange_operating_mic: exchange_operating_mic, ) security.assign_attributes( country_code: country_code, offline: true # This tells us that we shouldn't try to fetch prices later ) security.save! security end def exact_match_from_db security = Security.find_by( { ticker: symbol, exchange_operating_mic: exchange_operating_mic, country_code: country_code.presence }.compact ) return nil unless security # When the caller provides an explicit provider (e.g. user selected from # search results), honor that choice. Automated syncs (Plaid, SimpleFIN) # pass price_provider: nil and will not overwrite. if price_provider.present? && security.price_provider != price_provider security.update!(price_provider: price_provider) end reactivate_if_provider_available!(security) security end # If provided a ticker + exchange (and optionally, a country code), we can find exact matches def exact_match_from_provider # Without an exchange, we can never know if we have an exact match return nil unless exchange_operating_mic.present? match = provider_search_result.find do |s| ticker_matches = s.ticker&.upcase.to_s == symbol.upcase.to_s exchange_matches = s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s if country_code && exchange_operating_mic ticker_matches && exchange_matches && country_matches?(s.country_code) else ticker_matches && exchange_matches end end return nil unless match find_or_create_provider_match!(match) end def close_match_from_provider filtered_candidates = provider_search_result # If a country code is specified, we MUST find a match with the same code # — but nil candidate country is treated as a wildcard (e.g. crypto from # Binance, which isn't tied to a jurisdiction). if country_code.present? filtered_candidates = filtered_candidates.select { |s| country_matches?(s.country_code) } end # 1. Prefer exact ticker matches (MSTR before MSTRX when searching for "MSTR") # 2. Prefer exact exchange_operating_mic matches (if one was provided) # 3. Rank by country relevance (lower index in the list is more relevant) # 4. Rank by exchange_operating_mic relevance (lower index in the list is more relevant) sorted_candidates = filtered_candidates.sort_by do |s| [ s.ticker&.upcase.to_s == symbol.upcase.to_s ? 0 : 1, exchange_operating_mic.present? && s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length, sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length ] end match = sorted_candidates.first return nil unless match find_or_create_provider_match!(match) end def find_or_create_provider_match!(match) security = Security.find_or_initialize_by( ticker: match.ticker, exchange_operating_mic: match.exchange_operating_mic, ) security.country_code = match.country_code # Set provider when explicitly provided (user selection) or when the # record is new / has no provider yet. Automated syncs pass nil and # will not overwrite an existing choice. effective_provider = price_provider.presence || (match.respond_to?(:price_provider) ? match.price_provider.presence : nil) if effective_provider.present? security.price_provider = effective_provider end security.save! reactivate_if_provider_available!(security) security end # If a security was marked offline (e.g. its provider was temporarily # removed in settings) but now has a valid, enabled provider, bring it # back online so the MarketDataImporter picks it up again. def reactivate_if_provider_available!(security) return unless security.offline? return unless security.offline_reason == "provider_disabled" return unless security.price_data_provider.present? security.update!(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) end # Candidate country matches when it equals the resolver's country OR when # the provider didn't report a country at all (e.g. crypto from Binance). # A nil candidate country is a legitimate "no jurisdiction" signal, not a # missing field, so we trust the user's provider + exchange pick. def country_matches?(candidate_country) return true if candidate_country.blank? candidate_country.upcase == country_code.upcase end def provider_search_result params = { exchange_operating_mic: exchange_operating_mic, country_code: country_code }.compact_blank @provider_search_result ||= Security.search_provider(symbol, **params) end # Non-exhaustive list of common country codes for help in choosing "close" matches # User's country (if provided) is prioritized first, then sorted by market cap. def sorted_country_codes_by_relevance base_order = %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO] # Prioritize user's country if provided if country_code.present? user_country = country_code.upcase [ user_country ] + (base_order - [ user_country ]) else base_order end end # Non-exhaustive list of common exchange operating MICs for help in choosing "close" matches # This is very US-centric since our prices provider and user base is a majority US-based def sorted_exchange_operating_mics_by_relevance [ "XNYS", # New York Stock Exchange "XNAS", # NASDAQ Stock Market "XOTC", # OTC Markets Group (OTC Link) "OTCM", # OTC Markets Group "OTCN", # OTC Bulletin Board "OTCI", # OTC International "OPRA", # Options Price Reporting Authority "MEMX", # Members Exchange "IEXA", # IEX All-Market "IEXG", # IEX Growth Market "EDXM", # Cboe EDGX Exchange (Equities) "XCME", # CME Group (Derivatives) "XCBT", # Chicago Board of Trade "XPUS", # Nasdaq PSX (U.S.) "XPSE", # Nasdaq PHLX (U.S.) "XTRD", # Nasdaq TRF (Trade Reporting Facility) "XTXD", # FINRA TRACE (Trade Reporting) "XARC", # NYSE Arca "XBOX", # BOX Options Exchange "XBXO" # BZX Options (Cboe) ] end end