Files
sure/app/models/security.rb
soky srm 0aca297e9c Add binance security provider for crypto (#1424)
* 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
2026-04-10 15:43:22 +02:00

165 lines
5.6 KiB
Ruby

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
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
# Single source of truth for which logo URL the UI should render. The order
# differs by asset class:
#
# - Crypto: prefer the verified per-asset jsDelivr logo (set during
# import by Provider::BinancePublic#verified_logo_url). On a miss, fall
# back to Brandfetch with a forced `binance.com` identifier so the
# generic Binance brand mark shows instead of a ticker lettermark.
#
# - Everything else: Brandfetch first (domain-derived or ticker lettermark),
# then any provider-set logo_url.
def display_logo_url
if crypto?
logo_url.presence || brandfetch_icon_url(identifier: "binance.com")
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, identifier: 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?
url = brandfetch_icon_url
return false unless url.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 = brandfetch_icon_url
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