Files
sure/app/models/provider/binance_public.rb
soky srm dcebda05de Move back to brandfetch (#1427)
* Move back to brandfetch

* Update security.rb

* Update security.rb
2026-04-10 17:42:16 +02:00

291 lines
10 KiB
Ruby

class Provider::BinancePublic < Provider
include SecurityConcept, RateLimitable
extend SslConfigurable
Error = Class.new(Provider::Error)
InvalidSecurityPriceError = Class.new(Error)
RateLimitError = Class.new(Error)
MIN_REQUEST_INTERVAL = 0.1
# Binance's official ISO 10383 operating MIC (assigned Jan 2026, country AE).
# Crypto is not tied to a national jurisdiction, so we intentionally do NOT
# propagate the ISO-assigned country code to search results — the resolver
# treats a nil candidate country as a wildcard, letting any family resolve
# a Binance pick regardless of their own country.
BINANCE_MIC = "BNCX".freeze
# Quote assets we expose in search results. Order = preference when multiple
# quote variants exist for the same base asset. USDT is Binance's dominant
# dollar quote and is surfaced to users as USD. GBP is absent because
# Binance has zero GBP trading pairs today; GBP-family users fall back to
# USDT->USD via the app's FX conversion, same as HUF/CZK/PLN users.
SUPPORTED_QUOTES = %w[USDT EUR JPY BRL TRY].freeze
# Binance quote asset -> user-facing currency & ticker suffix.
QUOTE_TO_CURRENCY = {
"USDT" => "USD",
"EUR" => "EUR",
"JPY" => "JPY",
"BRL" => "BRL",
"TRY" => "TRY"
}.freeze
KLINE_MAX_LIMIT = 1000
MS_PER_DAY = 24 * 60 * 60 * 1000
SEARCH_LIMIT = 25
def initialize
# No API key required — public market data only.
end
def healthy?
with_provider_response do
client.get("#{base_url}/api/v3/ping")
true
end
end
def usage
with_provider_response do
UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)")
end
end
# ================================
# Securities
# ================================
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
with_provider_response do
query = symbol.to_s.strip.upcase
next [] if query.empty?
symbols = exchange_info_symbols
matches = symbols.select do |s|
base = s["baseAsset"].to_s.upcase
quote = s["quoteAsset"].to_s.upcase
symbol = s["symbol"].to_s.upcase
next false unless SUPPORTED_QUOTES.include?(quote)
# Match on either the base asset (so "BTC" surfaces every BTC pair) or
# the full Binance pair symbol (so users pasting their own portfolio
# ticker like "BTCEUR" or "BTCUSD" — which prefixes Binance's raw
# "BTCUSDT" — also hit a result).
base.include?(query) || symbol == query || symbol.start_with?(query)
end
ranked = matches.sort_by do |s|
base = s["baseAsset"].to_s.upcase
quote = s["quoteAsset"].to_s.upcase
symbol = s["symbol"].to_s.upcase
quote_index = SUPPORTED_QUOTES.index(quote) || 99
relevance = if symbol == query
0 # exact full-ticker match — highest priority
elsif symbol.start_with?(query)
1 # ticker prefix match (e.g. "BTCUSD" against "BTCUSDT")
elsif base == query
2 # exact base-asset match (e.g. "BTC")
elsif base.start_with?(query)
3
else
4
end
[ relevance, quote_index, base ]
end
ranked.first(SEARCH_LIMIT).map do |s|
base = s["baseAsset"].to_s.upcase
quote = s["quoteAsset"].to_s.upcase
display_currency = QUOTE_TO_CURRENCY[quote]
Security.new(
symbol: "#{base}#{display_currency}",
name: base,
# Brandfetch /crypto/{base} URL — unknown coins (rare) will 400 and
# render as a broken img in the dropdown; same tradeoff as stocks
# with obscure tickers. `::Security` reaches the AR model —
# unqualified `Security` here resolves to the Data value-object
# from SecurityConcept.
logo_url: ::Security.brandfetch_crypto_url(base),
exchange_operating_mic: BINANCE_MIC,
country_code: nil,
currency: display_currency
)
end
end
end
def fetch_security_info(symbol:, exchange_operating_mic:)
with_provider_response do
parsed = parse_ticker(symbol)
raise Error, "Unsupported Binance ticker: #{symbol}" if parsed.nil?
# logo_url is intentionally nil — crypto logos are set at save time by
# Security#generate_logo_url_from_brandfetch via the /crypto/{base}
# route, not returned from this provider.
SecurityInfo.new(
symbol: symbol,
name: parsed[:base],
links: "https://www.binance.com/en/trade/#{parsed[:binance_pair]}",
logo_url: nil,
description: nil,
kind: "crypto",
exchange_operating_mic: exchange_operating_mic
)
end
end
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
with_provider_response do
historical = fetch_security_prices(
symbol: symbol,
exchange_operating_mic: exchange_operating_mic,
start_date: date,
end_date: date
)
raise historical.error if historical.error.present?
raise InvalidSecurityPriceError, "No price found for #{symbol} on #{date}" if historical.data.blank?
historical.data.first
end
end
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
with_provider_response do
parsed = parse_ticker(symbol)
raise InvalidSecurityPriceError, "Unsupported Binance ticker: #{symbol}" if parsed.nil?
binance_pair = parsed[:binance_pair]
display_currency = parsed[:display_currency]
prices = []
cursor = start_date
seen_data = false
while cursor <= end_date
window_end = [ cursor + (KLINE_MAX_LIMIT - 1).days, end_date ].min
throttle_request
response = client.get("#{base_url}/api/v3/klines") do |req|
req.params["symbol"] = binance_pair
req.params["interval"] = "1d"
req.params["startTime"] = date_to_ms(cursor)
req.params["endTime"] = date_to_ms(window_end) + MS_PER_DAY - 1
req.params["limit"] = KLINE_MAX_LIMIT
end
batch = JSON.parse(response.body)
if batch.empty?
# Empty window. Two cases:
# 1. cursor is before the pair's listing date — keep advancing
# until we hit the first window containing valid klines.
# Critical for long-range imports (e.g. account sync from a
# trade start date that predates the Binance listing).
# 2. We have already collected prices and this window is past
# the end of available history — stop to avoid wasted calls
# on delisted pairs.
break if seen_data
else
seen_data = true
batch.each do |row|
open_time_ms = row[0].to_i
close_price = row[4].to_f
next if close_price <= 0
prices << Price.new(
symbol: symbol,
date: Time.at(open_time_ms / 1000).utc.to_date,
price: close_price,
currency: display_currency,
exchange_operating_mic: exchange_operating_mic
)
end
end
# Note: we intentionally do NOT break on a short (non-empty) batch.
# A window that straddles the pair's listing date legitimately returns
# fewer than KLINE_MAX_LIMIT rows while there is still valid data in
# subsequent windows.
cursor = window_end + 1.day
end
prices
end
end
private
def base_url
ENV["BINANCE_PUBLIC_URL"] || "https://data-api.binance.vision"
end
def client
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
# Explicit timeouts so a hanging Binance endpoint can't stall a Sidekiq
# worker or Puma thread indefinitely. Values are deliberately generous
# enough for a full 1000-row klines response but capped to bound the
# worst-case retry chain (3 attempts * 20s + backoff ~= 65s).
faraday.options.open_timeout = 5
faraday.options.timeout = 20
faraday.request(:retry, {
max: 3,
interval: 0.5,
interval_randomness: 0.5,
backoff_factor: 2,
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ]
})
faraday.request :json
faraday.response :raise_error
faraday.headers["Accept"] = "application/json"
end
end
# Maps a user-visible ticker (e.g. "BTCUSD", "ETHEUR") to the Binance pair
# symbol, base asset, and display currency. Returns nil if the ticker does
# not end with a supported quote currency.
def parse_ticker(ticker)
ticker_up = ticker.to_s.upcase
SUPPORTED_QUOTES.each do |quote|
display_currency = QUOTE_TO_CURRENCY[quote]
next unless ticker_up.end_with?(display_currency)
base = ticker_up.delete_suffix(display_currency)
next if base.empty?
return { binance_pair: "#{base}#{quote}", base: base, display_currency: display_currency }
end
nil
end
# Cached for 24h — exchangeInfo returns the full symbol universe (thousands
# of rows, weight 10) and rarely changes.
def exchange_info_symbols
Rails.cache.fetch("binance_public:exchange_info", expires_in: 24.hours) do
throttle_request
response = client.get("#{base_url}/api/v3/exchangeInfo")
parsed = JSON.parse(response.body)
(parsed["symbols"] || []).select { |s| s["status"] == "TRADING" }
end
end
def date_to_ms(date)
Time.utc(date.year, date.month, date.day).to_i * 1000
end
# Preserve BinancePublic::Error subclasses (e.g. InvalidSecurityPriceError)
# through with_provider_response. The inherited RateLimitable transformer
# only preserves RateLimitError and would otherwise downcast our custom
# errors to the generic Error class.
def default_error_transformer(error)
return error if error.is_a?(self.class::Error)
super
end
end