Files
sure/app/models/provider/binance.rb
T
UnamedRusandClaude Opus 4.8 8c52e20906 fix(binance): correct base_url for futures api endpoint, start_time param (#2839)
* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Fix parameter naming for get_spot_trades and get_futures_trades

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update processor_test.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* reset startTime after fromId

* Fix Binance trade sync param conflict with windowed initial fetch

Binance rejects fromId combined with startTime/endTime, and an unbounded
startTime (default sync ~1yr) exceeds the window cap (spot 24h, futures 7d),
so the initial sync could fail or miss trades.

Split fetch_new_trades into two non-mixing paths:
- incremental (cached trades): fromId-only pagination
- initial sync: walk forward in fixed windows (24h spot / 7d futures),
  clamped to the 6-month futures lookback

Add endTime param to get_spot_trades/get_futures_trades. Add tests covering
multi-window initial sync and multi-page fromId pagination.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 23:55:18 +02:00

198 lines
6.6 KiB
Ruby

class Provider::Binance
include HTTParty
extend SslConfigurable
class Error < StandardError; end
class AuthenticationError < Error; end
class RateLimitError < Error; end
class ApiError < Error; end
class InvalidSymbolError < ApiError; end
# Pipelock false positive: This constant and the base_uri below trigger a "Credential in URL"
# warning because of the presence of @api_key and @api_secret variables in this file.
# Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter
# in an URL (e.g. https://user:password@host).
SPOT_BASE_URL = "https://api.binance.com".freeze
FUTURES_BASE_URL = "https://fapi.binance.com".freeze
base_uri SPOT_BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:)
@api_key = api_key
@api_secret = api_secret
end
# Spot wallet — requires signed request
def get_spot_account
signed_get("/api/v3/account")
end
# Margin account — requires signed request
def get_margin_account
signed_get("/sapi/v1/margin/account")
end
# Simple Earn flexible positions — requires signed request
def get_simple_earn_flexible
signed_get("/sapi/v1/simple-earn/flexible/position")
end
# Simple Earn locked positions — requires signed request
def get_simple_earn_locked
signed_get("/sapi/v1/simple-earn/locked/position")
end
# Public endpoint — no auth needed
# symbol e.g. "BTCUSDT"
# Returns price string or nil on failure
def get_spot_price(symbol)
response = self.class.get("/api/v3/ticker/price", query: { symbol: symbol })
data = handle_response(response)
data["price"]
rescue StandardError => e
Rails.logger.warn("Provider::Binance: failed to fetch price for #{symbol}: #{e.message}")
nil
end
# Public endpoint — fetch historical kline close price for a date
# symbol e.g. "BTCUSDT", date e.g. Date or Time
def get_historical_price(symbol, date)
timestamp = date.to_time.utc.beginning_of_day.to_i * 1000
response = self.class.get("/api/v3/klines", query: {
symbol: symbol,
interval: "1d",
startTime: timestamp,
limit: 1
})
data = handle_response(response)
return nil if data.blank? || data.first.blank?
# Binance klines format: [ Open time, Open, High, Low, Close (index 4), ... ]
data.first[4]
rescue StandardError => e
Rails.logger.warn("Provider::Binance: failed to fetch historical price for #{symbol} on #{date}: #{e.message}")
nil
end
# Signed trade history for a single symbol, e.g. "BTCUSDT".
# Pass from_id to fetch only trades with id >= from_id (for incremental sync).
# Pass start_time/end_time (epoch ms) to fetch a bounded window instead; Binance
# rejects from_id combined with start_time/end_time, so callers use one or the other.
def get_spot_trades(symbol, limit: 1000, from_id: nil, start_time: nil, end_time: nil)
params = { "symbol" => symbol, "limit" => limit.to_s }
params["fromId"] = from_id.to_s if from_id
params["startTime"] = start_time.to_s if start_time
params["endTime"] = end_time.to_s if end_time
signed_get("/api/v3/myTrades", extra_params: params)
end
# USDⓈ-M Futures account — requires signed request
def get_futures_account
signed_get("/fapi/v2/account", base_url: FUTURES_BASE_URL)
end
# Futures trade history for a single symbol
def get_futures_trades(symbol, limit: 1000, from_id: nil, start_time: nil, end_time: nil)
params = { "symbol" => symbol, "limit" => limit.to_s }
params["fromId"] = from_id.to_s if from_id
params["startTime"] = start_time.to_s if start_time
params["endTime"] = end_time.to_s if end_time
signed_get("/fapi/v1/userTrades", extra_params: params, base_url: FUTURES_BASE_URL)
end
# P2P trade history — requires signed request
# Pass start_timestamp to fetch only recent trades (max 30 days window)
def get_p2p_trades(start_timestamp: nil, end_timestamp: nil)
params = { "tradeType" => "BUY" } # default to BUY, will loop in processor for SELL
params["startTimestamp"] = start_timestamp.to_s if start_timestamp
params["endTimestamp"] = end_timestamp.to_s if end_timestamp
signed_get("/sapi/v1/c2c/orderMatch/listUserOrderHistory", extra_params: params)
end
# Internal helper to handle both buy and sell types since API requires specific tradeType or gets default BUY
def get_all_p2p_trades(start_timestamp: nil, end_timestamp: nil)
%w[BUY SELL].flat_map do |trade_type|
page = 1
rows = 100
data = []
loop do
result = signed_get(
"/sapi/v1/c2c/orderMatch/listUserOrderHistory",
extra_params: {
"tradeType" => trade_type,
"startTimestamp" => start_timestamp&.to_s,
"endTimestamp" => end_timestamp&.to_s,
"page" => page.to_s,
"rows" => rows.to_s
}.compact
)
batch = result.is_a?(Hash) ? Array(result["data"]) : []
data.concat(batch)
break if batch.size < rows
page += 1
end
data
end
end
private
def signed_get(path, extra_params: {}, base_url: SPOT_BASE_URL)
params = timestamp_params.merge(extra_params)
query_string = URI.encode_www_form(params.sort)
response = self.class.get(
path,
base_uri: base_url,
query: "#{query_string}&signature=#{sign(query_string)}",
headers: auth_headers
)
handle_response(response)
end
def timestamp_params
{ "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" }
end
# HMAC-SHA256 of the query string.
# Accepts either a Hash of params or a pre-built query string.
def sign(params)
query_string = params.is_a?(Hash) ? URI.encode_www_form(params.sort) : params
OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string)
end
def auth_headers
{ "X-MBX-APIKEY" => api_key }
end
def handle_response(response)
parsed = response.parsed_response
case response.code
when 200..299
parsed
when 401
raise AuthenticationError, extract_error_message(parsed) || "Unauthorized"
when 429
raise RateLimitError, "Rate limit exceeded"
else
msg = extract_error_message(parsed) || "API error: #{response.code}"
raise InvalidSymbolError, msg if parsed.is_a?(Hash) && parsed["code"] == -1121
raise ApiError, msg
end
end
def extract_error_message(parsed)
return parsed if parsed.is_a?(String)
return nil unless parsed.is_a?(Hash)
parsed["msg"] || parsed["message"] || parsed["error"]
end
end