mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
Adds Provider::TinkoffInvest, a token-based securities provider built on the public T-Invest REST gateway (invest-public-api.tinkoff.ru/rest). It serves prices for Russian instruments (shares, ETF/БПИФ, bonds) and, crucially, brand logos via the T-Invest CDN — the authoritative logo source for MOEX instruments, which ISS (MoexPublic) does not provide. - Registry: register `tinkoff_invest` under the :securities concept; token via ENV TINKOFF_INVEST_API_KEY or encrypted Setting.tinkoff_invest_api_key. - Logos independent of the price provider: Security#import_brand_logo consults T-Invest for a logo whenever a token is configured (after the price-provider metadata fetch, so it never short-circuits website_url backfill). Gated on token presence, not the securities checklist. - display_logo_url: with no website domain, a stored provider logo (T-Invest) now beats the ticker-only Brandfetch lettermark; when a domain exists, Brandfetch still wins (unchanged). - MoexPublic no longer reports moex.com as the issuer website — it's the exchange, not the issuer, and would make Brandfetch render the exchange logo for every instrument and shadow the real brand logo. - Prices: GetCandles (daily, paged) + GetLastPrices; Quotation units+nano/1e9; bonds priced as percent-of-par x nominal (missing nominal raises, not 0). - Settings: encrypted token field (always shown) + provider checkbox + en locale. - Tests for search/info/logo-url/prices/bond/incomplete-candle and display logic. Co-authored-by: Claude <noreply@anthropic.com>
194 lines
7.0 KiB
Ruby
194 lines
7.0 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
|
|
|
|
# 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, and to represent non-primary-currency cash positions as holdings
|
|
# (issue #1809). When a currency that differs from the account's primary
|
|
# currency is given, a distinct per-currency security is created so balances
|
|
# in different currencies don't collide.
|
|
def self.cash_for(account, currency: nil)
|
|
distinct = currency.present? && currency.to_s.upcase != account.currency.to_s.upcase
|
|
ticker = (distinct ? "CASH-#{account.id}-#{currency}" : "CASH-#{account.id}").upcase
|
|
find_or_create_by!(ticker: ticker, kind: "cash") do |s|
|
|
s.name = distinct ? "Cash (#{currency.to_s.upcase})" : "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 keeps its dedicated Brandfetch-crypto shape.
|
|
# - When a website domain is known, Brandfetch (consistent client_id + size)
|
|
# wins, falling back to any stored logo_url.
|
|
# - With no domain, a stored provider logo (e.g. T-Invest's CDN for MOEX
|
|
# instruments) is authoritative and beats the ticker-only Brandfetch
|
|
# lettermark placeholder.
|
|
def display_logo_url
|
|
if crypto?
|
|
self.class.brandfetch_crypto_url(crypto_base_asset).presence || logo_url.presence
|
|
elsif website_url.present?
|
|
brandfetch_icon_url.presence || logo_url.presence
|
|
else
|
|
logo_url.presence || brandfetch_icon_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
|