mirror of
https://github.com/we-promise/sure.git
synced 2026-04-12 08:37:22 +00:00
* Binance as securities provider * Disable twelve data crypto results * Add logo support and new currency pairs * FIX importer fallback * Add price clamping and optiimize retrieval * Review * Update adding-a-securities-provider.md * day gap miss fix * New fixes * Brandfetch doesn't support crypto. add new CDN * Update _investment_performance.html.erb
225 lines
8.2 KiB
Ruby
225 lines
8.2 KiB
Ruby
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
|