mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
fix(binance): support CRYPTO: prefix and USD stablecoins (#1771)
* 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>
This commit is contained in:
@@ -35,6 +35,16 @@ class Provider::BinancePublic < Provider
|
||||
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
|
||||
@@ -58,9 +68,13 @@ class Provider::BinancePublic < Provider
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
with_provider_response do
|
||||
query = symbol.to_s.strip.upcase
|
||||
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|
|
||||
@@ -128,10 +142,12 @@ class Provider::BinancePublic < Provider
|
||||
# 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: "https://www.binance.com/en/trade/#{parsed[:binance_pair]}",
|
||||
links: links,
|
||||
logo_url: nil,
|
||||
description: nil,
|
||||
kind: "crypto",
|
||||
@@ -161,6 +177,10 @@ class Provider::BinancePublic < Provider
|
||||
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 = []
|
||||
@@ -220,6 +240,36 @@ class Provider::BinancePublic < Provider
|
||||
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
|
||||
@@ -247,11 +297,24 @@ class Provider::BinancePublic < Provider
|
||||
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.
|
||||
# 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)
|
||||
ticker_up = ticker.to_s.upcase
|
||||
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)
|
||||
@@ -259,9 +322,22 @@ class Provider::BinancePublic < Provider
|
||||
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
|
||||
nil
|
||||
|
||||
# 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
|
||||
|
||||
@@ -68,6 +68,38 @@ class Provider::BinancePublicTest < ActiveSupport::TestCase
|
||||
assert_equal [ "BTCUSD" ], response.data.map(&:symbol)
|
||||
end
|
||||
|
||||
test "search_securities strips CRYPTO: prefix from holdings-processor symbols" do
|
||||
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
|
||||
|
||||
response = @provider.search_securities("CRYPTO:BTC")
|
||||
|
||||
assert response.success?
|
||||
assert_includes response.data.map(&:symbol), "BTCUSD"
|
||||
end
|
||||
|
||||
test "search_securities returns a synthetic stablecoin result without hitting exchangeInfo" do
|
||||
@provider.expects(:exchange_info_symbols).never
|
||||
|
||||
response = @provider.search_securities("CRYPTO:USDT")
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
row = response.data.first
|
||||
assert_equal "USDTUSD", row.symbol
|
||||
assert_equal "USDT", row.name
|
||||
assert_equal "USD", row.currency
|
||||
assert_equal "BNCX", row.exchange_operating_mic
|
||||
assert_nil row.country_code
|
||||
end
|
||||
|
||||
test "parse_ticker treats stablecoin/USD search-result form as stablecoin" do
|
||||
parsed = @provider.send(:parse_ticker, "USDTUSD")
|
||||
assert parsed[:stablecoin]
|
||||
assert_nil parsed[:binance_pair]
|
||||
assert_equal "USDT", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "search_securities returns empty array when query does not match" do
|
||||
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
|
||||
|
||||
@@ -162,6 +194,95 @@ class Provider::BinancePublicTest < ActiveSupport::TestCase
|
||||
assert_nil @provider.send(:parse_ticker, "GIBBERISH")
|
||||
end
|
||||
|
||||
test "parse_ticker strips CRYPTO: prefix from holdings processors" do
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:BTCUSD")
|
||||
assert_equal "BTCUSDT", parsed[:binance_pair]
|
||||
assert_equal "BTC", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "parse_ticker flags USD stablecoins for synthetic pricing" do
|
||||
%w[USDT USDC BUSD DAI FDUSD TUSD USDP PYUSD].each do |stable|
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:#{stable}")
|
||||
assert parsed[:stablecoin], "expected #{stable} to be flagged as stablecoin"
|
||||
assert_nil parsed[:binance_pair]
|
||||
assert_equal stable, parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
end
|
||||
|
||||
test "parse_ticker defaults prefixed bare base assets to the USDT pair" do
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:SOL")
|
||||
assert_equal "SOLUSDT", parsed[:binance_pair]
|
||||
assert_equal "SOL", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "parse_ticker still rejects unprefixed malformed tickers" do
|
||||
# No CRYPTO: prefix → behaves like a Binance-search ticker (must end in a
|
||||
# supported fiat). Protects against false defaults like "BTCBNB" → "BTCBNBUSDT".
|
||||
assert_nil @provider.send(:parse_ticker, "SOL")
|
||||
assert_nil @provider.send(:parse_ticker, "BTCBNB")
|
||||
end
|
||||
|
||||
test "fetch_security_prices returns synthetic 1.0 USD prices for stablecoins" do
|
||||
# No HTTP call expected — short-circuited entirely.
|
||||
@provider.expects(:client).never
|
||||
|
||||
response = @provider.fetch_security_prices(
|
||||
symbol: "CRYPTO:USDT",
|
||||
exchange_operating_mic: "BNCX",
|
||||
start_date: Date.parse("2026-01-01"),
|
||||
end_date: Date.parse("2026-01-03")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 3, response.data.size
|
||||
assert response.data.all? { |p| p.price == 1.0 && p.currency == "USD" }
|
||||
assert_equal Date.parse("2026-01-01"), response.data.first.date
|
||||
assert_equal Date.parse("2026-01-03"), response.data.last.date
|
||||
end
|
||||
|
||||
test "fetch_security_price returns 1.0 USD for a stablecoin single day" do
|
||||
@provider.expects(:client).never
|
||||
|
||||
response = @provider.fetch_security_price(
|
||||
symbol: "CRYPTO:USDT",
|
||||
exchange_operating_mic: "BNCX",
|
||||
date: Date.parse("2026-01-15")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1.0, response.data.price
|
||||
assert_equal "USD", response.data.currency
|
||||
end
|
||||
|
||||
test "fetch_security_info handles stablecoin (no Binance pair link)" do
|
||||
response = @provider.fetch_security_info(symbol: "CRYPTO:USDT", exchange_operating_mic: "BNCX")
|
||||
|
||||
assert response.success?
|
||||
assert_equal "USDT", response.data.name
|
||||
assert_equal "crypto", response.data.kind
|
||||
assert_nil response.data.links
|
||||
end
|
||||
|
||||
test "fetch_security_prices resolves a bare CRYPTO: ticker against the USDT pair" do
|
||||
rows = [ kline_row("2026-01-15", "150.25") ]
|
||||
mock_client_returning_klines(rows)
|
||||
|
||||
response = @provider.fetch_security_prices(
|
||||
symbol: "CRYPTO:SOL",
|
||||
exchange_operating_mic: "BNCX",
|
||||
start_date: Date.parse("2026-01-15"),
|
||||
end_date: Date.parse("2026-01-15")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
assert_equal "USD", response.data.first.currency
|
||||
assert_in_delta 150.25, response.data.first.price
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Single price
|
||||
# ================================
|
||||
|
||||
Reference in New Issue
Block a user