class Security < ApplicationRecord include Provided, PlanRestrictionTracker # Transient attribute for search results -- not persisted attr_accessor :search_currency # ISO 10383 MIC codes mapped to user-friendly exchange names # Source: https://www.iso20022.org/market-identifier-codes # Data stored in config/exchanges.yml EXCHANGES = YAML.safe_load_file(Rails.root.join("config", "exchanges.yml")).freeze KINDS = %w[standard cash].freeze # Known securities provider keys — derived from the registry so adding a new # provider to Registry#available_providers automatically allows it here. # Evaluated at runtime (not boot) so runtime-enabled providers are accepted. def self.valid_price_providers Provider::Registry.for_concept(:securities).provider_keys.map(&:to_s) end # Builds the Brandfetch crypto URL for a base asset (e.g. "BTC"). Returns # nil when Brandfetch isn't configured. def self.brandfetch_crypto_url(base_asset) return nil if base_asset.blank? return nil unless Setting.brand_fetch_client_id.present? size = Setting.brand_fetch_logo_size "https://cdn.brandfetch.io/crypto/#{base_asset}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}" end before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? before_save :reset_first_provider_price_on_if_provider_changed has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } validates :kind, inclusion: { in: KINDS } validates :price_provider, inclusion: { in: ->(_) { Security.valid_price_providers } }, allow_nil: true scope :online, -> { where(offline: false) } scope :standard, -> { where(kind: "standard") } # Parses the combobox ID format "SYMBOL|EXCHANGE|PROVIDER" into a hash. def self.parse_combobox_id(value) parts = value.to_s.split("|", 3) { ticker: parts[0].presence, exchange_operating_mic: parts[1].presence, price_provider: parts[2].presence } end # Lazily finds or creates a synthetic cash security for an account. # Used as fallback when creating an interest Trade without a user-selected security. def self.cash_for(account) ticker = "CASH-#{account.id}".upcase find_or_create_by!(ticker: ticker, kind: "cash") do |s| s.name = "Cash" s.offline = true end end def cash? kind == "cash" end # True when this security represents a crypto asset. Today the only signal # is the Binance ISO MIC — when we add a second crypto provider, extend # this check rather than duplicating the test at every call site. def crypto? exchange_operating_mic == Provider::BinancePublic::BINANCE_MIC end # Strips the display-currency suffix from a crypto ticker (BTCUSD -> BTC, # ETHEUR -> ETH). Returns nil for non-crypto securities or when the ticker # doesn't end in a supported quote. def crypto_base_asset return nil unless crypto? Provider::BinancePublic::QUOTE_TO_CURRENCY.each_value do |suffix| next unless ticker.end_with?(suffix) base = ticker.delete_suffix(suffix) return base unless base.empty? end nil end # Single source of truth for which logo URL the UI should render. Crypto # and stocks share the same shape: prefer a freshly computed Brandfetch # URL (honors current client_id + size) and fall back to any stored # logo_url for the provider-returns-its-own-URL case (e.g. Tiingo S3). def display_logo_url if crypto? self.class.brandfetch_crypto_url(crypto_base_asset).presence || logo_url.presence else brandfetch_icon_url.presence || logo_url.presence end end # Returns user-friendly exchange name for a MIC code def self.exchange_name_for(mic) return nil if mic.blank? EXCHANGES.dig(mic.upcase, "name") || mic.upcase end def exchange_name self.class.exchange_name_for(exchange_operating_mic) end def current_price @current_price ||= find_or_fetch_price return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end def to_combobox_option ComboboxOption.new( symbol: ticker, name: name, logo_url: logo_url, exchange_operating_mic: exchange_operating_mic, country_code: country_code, price_provider: price_provider, currency: search_currency ) end def brandfetch_icon_url(width: nil, height: nil) return nil unless Setting.brand_fetch_client_id.present? w = width || Setting.brand_fetch_logo_size h = height || Setting.brand_fetch_logo_size identifier = extract_domain(website_url) if website_url.present? identifier ||= ticker return nil unless identifier.present? "https://cdn.brandfetch.io/#{identifier}/icon/fallback/lettermark/w/#{w}/h/#{h}?c=#{Setting.brand_fetch_client_id}" end private def extract_domain(url) uri = URI.parse(url) host = uri.host || url host.sub(/\Awww\./, "") rescue URI::InvalidURIError nil end def upcase_symbols self.ticker = ticker.upcase self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present? end def should_generate_logo? return false if cash? return false unless Setting.brand_fetch_client_id.present? return true if logo_url.blank? return false unless logo_url.include?("cdn.brandfetch.io") website_url_changed? || ticker_changed? end def generate_logo_url_from_brandfetch self.logo_url = if crypto? self.class.brandfetch_crypto_url(crypto_base_asset) else brandfetch_icon_url end end # When a user remaps a security to a different provider (via the holdings # remap combobox or Security::Resolver), the previously-discovered # first_provider_price_on belongs to the OLD provider and may no longer # reflect what the new provider can serve. Reset it so the next sync's # fallback rediscovers the correct earliest date for the new provider. # Skip when the caller explicitly set both columns in the same save. def reset_first_provider_price_on_if_provider_changed return unless price_provider_changed? return if first_provider_price_on_changed? self.first_provider_price_on = nil end end