mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* fix(binance): support CRYPTO: prefix and USD stablecoins Holdings processors (CoinStats, Coinbase, Kraken, SimpleFIN, Lunchflow, Binance) store crypto securities with a "CRYPTO:" prefix, but Provider::BinancePublic#parse_ticker only accepted Binance-search-style tickers like "BTCUSD". As a result, every fetched price for tickers like CRYPTO:USDT, CRYPTO:USDC, CRYPTO:SOL, CRYPTO:TRUMP, CRYPTO:KAITO failed with "Unsupported Binance ticker". - Strip the CRYPTO: prefix in parse_ticker. - Short-circuit USD-pegged stablecoins (USDT, USDC, BUSD, DAI, FDUSD, TUSD, USDP, PYUSD) to a synthetic flat 1.0 USD price. Binance has no self-pair (USDTUSDT is invalid), and the few stablecoin/USDT pairs that do exist hover at ~1.0 with sub-cent noise. - Default prefixed bare base assets (CRYPTO:SOL etc.) to the …USDT pair (USD). Only when prefixed, so unprefixed garbage like BTCBNB / BTCGBP still returns nil and the existing rejection tests still pass. - fetch_security_info returns links: nil for stablecoins rather than a broken /trade/ URL. Closes #1441. * fix(binance): strip CRYPTO: prefix in search_securities Security::Resolver calls search_provider with the raw holdings-processor symbol (CRYPTO:SOL, CRYPTO:USDT) before any price fetch. Without prefix handling here, first-time crypto imports never resolve to an online Binance security and the new stablecoin/prefix paths in parse_ticker were unreachable for that flow. - Strip CRYPTO: from the search query. - Short-circuit USD stablecoins to a synthetic search result (no exchangeInfo call, no Binance self-pair to find). - Teach parse_ticker the "{stablecoin}USD" form produced by the synthetic result so price fetches route to stablecoin_prices. --------- Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
367 lines
13 KiB
Ruby
367 lines
13 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
|
|
|
|
# USD-pegged stablecoins. Binance has no self-pair (USDTUSDT is invalid) and
|
|
# the few stablecoin/USDT pairs that do exist (USDCUSDT, etc.) hover at ~1.0
|
|
# with sub-cent noise — synthesizing a flat 1.0 USD price is both accurate
|
|
# enough and avoids surfacing transient depeg ticks from market data.
|
|
USD_STABLECOINS = %w[USDT USDC BUSD DAI FDUSD TUSD USDP PYUSD].freeze
|
|
|
|
# Symbol prefix applied by holdings processors (CoinStats, Coinbase, Kraken,
|
|
# Binance, SimpleFIN, Lunchflow) to distinguish crypto from stock tickers.
|
|
CRYPTO_PREFIX = "CRYPTO:".freeze
|
|
|
|
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.delete_prefix(CRYPTO_PREFIX)
|
|
next [] if query.empty?
|
|
|
|
if USD_STABLECOINS.include?(query)
|
|
next [ stablecoin_search_result(query) ]
|
|
end
|
|
|
|
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.
|
|
links = parsed[:binance_pair] ? "https://www.binance.com/en/trade/#{parsed[:binance_pair]}" : nil
|
|
|
|
SecurityInfo.new(
|
|
symbol: symbol,
|
|
name: parsed[:base],
|
|
links: links,
|
|
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?
|
|
|
|
if parsed[:stablecoin]
|
|
next stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic)
|
|
end
|
|
|
|
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
|
|
# Synthetic search hit for a USD-pegged stablecoin. Binance has no self-pair
|
|
# (USDTUSDT etc. don't exist), so we manufacture a result instead of letting
|
|
# the resolver fall back to an offline CRYPTO:* row. The downstream price
|
|
# path short-circuits via parse_ticker -> stablecoin_prices.
|
|
def stablecoin_search_result(base)
|
|
Security.new(
|
|
symbol: "#{base}USD",
|
|
name: base,
|
|
logo_url: ::Security.brandfetch_crypto_url(base),
|
|
exchange_operating_mic: BINANCE_MIC,
|
|
country_code: nil,
|
|
currency: "USD"
|
|
)
|
|
end
|
|
|
|
# Synthesize flat 1.0 USD prices for USD-pegged stablecoins across the
|
|
# requested range. Avoids a Binance round-trip (there is no self-pair like
|
|
# USDTUSDT) and produces stable values for portfolio aggregation.
|
|
def stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic)
|
|
(start_date..end_date).map do |date|
|
|
Price.new(
|
|
symbol: symbol,
|
|
date: date,
|
|
price: 1.0,
|
|
currency: parsed[:display_currency],
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
end
|
|
end
|
|
|
|
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 to the Binance pair symbol, base asset, and
|
|
# display currency. Accepts:
|
|
# - "BTCUSD"/"ETHEUR" — fiat suffix from search_securities output
|
|
# - "CRYPTO:BTCUSD" — prefixed form stored by holdings processors
|
|
# - "CRYPTO:SOL"/"SOL" — bare base asset; defaults to the USDT pair (USD)
|
|
# - "CRYPTO:USDT"/"USDT" — USD-pegged stablecoin; binance_pair is nil and
|
|
# callers short-circuit to a synthetic 1.0 USD price
|
|
# Returns nil only when the input is empty after stripping the prefix.
|
|
def parse_ticker(ticker)
|
|
raw = ticker.to_s.upcase
|
|
prefixed = raw.start_with?(CRYPTO_PREFIX)
|
|
ticker_up = raw.delete_prefix(CRYPTO_PREFIX)
|
|
return nil if ticker_up.empty?
|
|
|
|
if USD_STABLECOINS.include?(ticker_up)
|
|
return { binance_pair: nil, base: ticker_up, display_currency: "USD", stablecoin: true }
|
|
end
|
|
|
|
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?
|
|
|
|
# "{stablecoin}USD" form (e.g. "USDTUSD" produced by search_securities)
|
|
# routes to synthetic 1.0 USD pricing — there is no Binance self-pair.
|
|
if display_currency == "USD" && USD_STABLECOINS.include?(base)
|
|
return { binance_pair: nil, base: base, display_currency: "USD", stablecoin: true }
|
|
end
|
|
|
|
return { binance_pair: "#{base}#{quote}", base: base, display_currency: display_currency }
|
|
end
|
|
|
|
# No fiat suffix matched. Only treat the input as a bare base asset when
|
|
# it arrived with the CRYPTO: prefix from a holdings processor — that
|
|
# tells us it really is a single coin symbol (SOL, TRUMP, KAITO), not a
|
|
# malformed pair like "BTCBNB" or "BTCGBP" that we want to reject.
|
|
return nil unless prefixed
|
|
|
|
{ binance_pair: "#{ticker_up}USDT", base: ticker_up, display_currency: "USD" }
|
|
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
|